* Support POST health checks for HTTP RPC APIs * Preserve existing health-check method behavior ---------
Unified · Split
| @@ -0,0 +1,6 @@ | ||
| 1 | +--- | |
| 2 | +"@executor-js/plugin-openapi": patch | |
| 3 | +"@executor-js/react": patch | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Support POST health checks for APIs that expose reads through HTTP RPC. Warn that POST can change data, allow validated JSON request bodies, and display the reason when a configured probe cannot run. | |
| @@ -0,0 +1,238 @@ | ||
| 1 | +import { randomBytes } from "node:crypto"; | |
| 2 | +import { expect } from "@effect/vitest"; | |
| 3 | +import { Effect } from "effect"; | |
| 4 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 5 | +import { connectEmulator } from "@executor-js/emulate"; | |
| 6 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 7 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 8 | +import { variable } from "@executor-js/sdk/http-auth"; | |
| 9 | + | |
| 10 | +import { createEmulatorInstance } from "../src/emulator-instance"; | |
| 11 | +import { scenario } from "../src/scenario"; | |
| 12 | +import { Api, Browser, Target } from "../src/services"; | |
| 13 | +import { visit } from "../src/surfaces/browser"; | |
| 14 | + | |
| 15 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 16 | +const template = AuthTemplateSlug.make("apiKey"); | |
| 17 | +const name = ConnectionName.make("test"); | |
| 18 | +const authenticationTemplate = [ | |
| 19 | + { | |
| 20 | + slug: template, | |
| 21 | + type: "apiKey" as const, | |
| 22 | + headers: { authorization: ["Bearer ", variable("token")] }, | |
| 23 | + }, | |
| 24 | +]; | |
| 25 | +const spec = (baseUrl: string) => | |
| 26 | + JSON.stringify({ | |
| 27 | + openapi: "3.0.3", | |
| 28 | + info: { title: "RPC account health", version: "1" }, | |
| 29 | + servers: [{ url: baseUrl }], | |
| 30 | + paths: { | |
| 31 | + "/api/auth.test": { | |
| 32 | + post: { | |
| 33 | + operationId: "getAccount", | |
| 34 | + requestBody: { | |
| 35 | + required: true, | |
| 36 | + content: { "application/json": { schema: { type: "object" } } }, | |
| 37 | + }, | |
| 38 | + responses: { "200": { description: "OK" } }, | |
| 39 | + }, | |
| 40 | + }, | |
| 41 | + "/account": { | |
| 42 | + delete: { operationId: "deleteAccount", responses: { "204": { description: "Deleted" } } }, | |
| 43 | + }, | |
| 44 | + }, | |
| 45 | + }); | |
| 46 | + | |
| 47 | +scenario( | |
| 48 | + "Health checks (UI) · configure and run a POST probe with a warning", | |
| 49 | + {}, | |
| 50 | + Effect.scoped( | |
| 51 | + Effect.gen(function* () { | |
| 52 | + const target = yield* Target; | |
| 53 | + const browser = yield* Browser; | |
| 54 | + const { client: makeClient } = yield* Api; | |
| 55 | + const identity = yield* target.newIdentity(); | |
| 56 | + const client = yield* makeClient(api, identity); | |
| 57 | + const slug = IntegrationSlug.make(`hc-rpc-${randomBytes(4).toString("hex")}`); | |
| 58 | + const baseUrl = yield* createEmulatorInstance("slack", "rpc-health"); | |
| 59 | + const emulator = yield* Effect.promise(() => connectEmulator({ baseUrl })); | |
| 60 | + const credential = yield* Effect.promise(() => | |
| 61 | + emulator.credentials.mint({ type: "bearer-token" }), | |
| 62 | + ); | |
| 63 | + const token = credential.token; | |
| 64 | + if (!token) return yield* Effect.die("Emulator did not mint a bearer token"); | |
| 65 | + const body = { include: { identity: true }, fields: ["user", "team"] }; | |
| 66 | + | |
| 67 | + yield* Effect.ensuring( | |
| 68 | + Effect.gen(function* () { | |
| 69 | + yield* client.openapi.addSpec({ | |
| 70 | + payload: { | |
| 71 | + slug, | |
| 72 | + baseUrl, | |
| 73 | + spec: { kind: "blob", value: spec(baseUrl) }, | |
| 74 | + authenticationTemplate, | |
| 75 | + }, | |
| 76 | + }); | |
| 77 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 78 | + await step("Choose a POST health check and read its warning", async () => { | |
| 79 | + await visit(page, `/integrations/${slug}`); | |
| 80 | + await page.getByRole("button", { name: "Set up", exact: true }).click(); | |
| 81 | + await page | |
| 82 | + .getByRole("combobox", { name: "Operation", exact: true }) | |
| 83 | + .fill("getAccount"); | |
| 84 | + await page.getByRole("option", { name: /POST.*getAccount/ }).click(); | |
| 85 | + await page | |
| 86 | + .getByRole("alert") | |
| 87 | + .filter({ hasText: "POST requests can change data." }) | |
| 88 | + .waitFor(); | |
| 89 | + }); | |
| 90 | + await step("Reject malformed JSON before running or saving", async () => { | |
| 91 | + await page.getByRole("textbox", { name: "Request body (JSON)" }).fill('{"include":'); | |
| 92 | + expect( | |
| 93 | + await page.getByRole("button", { name: "Save", exact: true }).isEnabled(), | |
| 94 | + ).toBe(false); | |
| 95 | + await page.getByText("Enter a valid JSON request body.").waitFor(); | |
| 96 | + }); | |
| 97 | + await step("Preview the POST read with a JSON request body", async () => { | |
| 98 | + await page | |
| 99 | + .getByRole("textbox", { name: "Request body (JSON)" }) | |
| 100 | + .fill(JSON.stringify(body)); | |
| 101 | + await page.getByLabel("Test credential", { exact: true }).fill(token); | |
| 102 | + await page.getByRole("button", { name: "Preview", exact: true }).click(); | |
| 103 | + await page.getByText("Healthy", { exact: true }).waitFor(); | |
| 104 | + await page.getByText("Healthy", { exact: true }).scrollIntoViewIfNeeded(); | |
| 105 | + }); | |
| 106 | + await step("Save the health check and reopen its JSON body", async () => { | |
| 107 | + await page.getByRole("button", { name: "Save", exact: true }).click(); | |
| 108 | + await page.locator("#health-check-operation").waitFor({ state: "hidden" }); | |
| 109 | + await page.reload(); | |
| 110 | + const section = page.locator("section").filter({ | |
| 111 | + has: page.getByRole("heading", { name: "Health check", exact: true }), | |
| 112 | + }); | |
| 113 | + await section.getByRole("button", { name: "Edit", exact: true }).click(); | |
| 114 | + expect( | |
| 115 | + JSON.parse( | |
| 116 | + await page.getByRole("textbox", { name: "Request body (JSON)" }).inputValue(), | |
| 117 | + ), | |
| 118 | + ).toEqual(body); | |
| 119 | + await page | |
| 120 | + .getByRole("alert") | |
| 121 | + .filter({ hasText: "POST requests can change data." }) | |
| 122 | + .waitFor(); | |
| 123 | + }); | |
| 124 | + }); | |
| 125 | + const saved = yield* client.integrations.healthCheckGet({ params: { slug } }); | |
| 126 | + expect(saved?.args).toEqual({ body }); | |
| 127 | + yield* client.connections.create({ | |
| 128 | + payload: { owner: "org", integration: slug, name, template, value: token }, | |
| 129 | + }); | |
| 130 | + const result = yield* client.connections.checkHealth({ | |
| 131 | + params: { owner: "org", integration: slug, name }, | |
| 132 | + query: {}, | |
| 133 | + }); | |
| 134 | + expect(result.status).toBe("healthy"); | |
| 135 | + expect(result.httpStatus).toBe(200); | |
| 136 | + const requests = yield* Effect.promise(() => emulator.ledger.list()); | |
| 137 | + const probes = requests.filter((request) => request.path === "/api/auth.test"); | |
| 138 | + expect( | |
| 139 | + probes.length, | |
| 140 | + "preview and saved-connection check both reach the upstream", | |
| 141 | + ).toBeGreaterThanOrEqual(2); | |
| 142 | + for (const probe of probes) { | |
| 143 | + expect(probe.method).toBe("POST"); | |
| 144 | + expect(probe.request.body).toEqual(body); | |
| 145 | + expect(probe.response.status).toBe(200); | |
| 146 | + expect(probe.response.body).toMatchObject({ ok: true }); | |
| 147 | + expect(probe.sideEffects).toEqual([]); | |
| 148 | + } | |
| 149 | + }), | |
| 150 | + Effect.gen(function* () { | |
| 151 | + yield* client.connections | |
| 152 | + .remove({ params: { owner: "org", integration: slug, name } }) | |
| 153 | + .pipe(Effect.ignore); | |
| 154 | + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | |
| 155 | + yield* Effect.promise(() => emulator.reset()); | |
| 156 | + }), | |
| 157 | + ); | |
| 158 | + }), | |
| 159 | + ), | |
| 160 | +); | |
| 161 | + | |
| 162 | +scenario( | |
| 163 | + "Health checks (UI) · explain why an unsupported method cannot run", | |
| 164 | + {}, | |
| 165 | + Effect.scoped( | |
| 166 | + Effect.gen(function* () { | |
| 167 | + const target = yield* Target; | |
| 168 | + const browser = yield* Browser; | |
| 169 | + const { client: makeClient } = yield* Api; | |
| 170 | + const identity = yield* target.newIdentity(); | |
| 171 | + const client = yield* makeClient(api, identity); | |
| 172 | + const slug = IntegrationSlug.make(`hc-refused-${randomBytes(4).toString("hex")}`); | |
| 173 | + const baseUrl = "https://example.invalid"; | |
| 174 | + yield* Effect.ensuring( | |
| 175 | + Effect.gen(function* () { | |
| 176 | + yield* client.openapi.addSpec({ | |
| 177 | + payload: { | |
| 178 | + slug, | |
| 179 | + baseUrl, | |
| 180 | + spec: { kind: "blob", value: spec(baseUrl) }, | |
| 181 | + authenticationTemplate, | |
| 182 | + }, | |
| 183 | + }); | |
| 184 | + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); | |
| 185 | + const mutation = candidates.find((candidate) => candidate.method === "delete"); | |
| 186 | + if (!mutation) return yield* Effect.die("Expected an unsupported DELETE operation"); | |
| 187 | + yield* client.integrations.healthCheckSet({ | |
| 188 | + params: { slug }, | |
| 189 | + payload: { spec: { operation: mutation.operation } }, | |
| 190 | + }); | |
| 191 | + yield* client.connections.create({ | |
| 192 | + payload: { | |
| 193 | + owner: "org", | |
| 194 | + integration: slug, | |
| 195 | + name, | |
| 196 | + template, | |
| 197 | + value: "test-only-credential", | |
| 198 | + }, | |
| 199 | + }); | |
| 200 | + const result = yield* client.connections.checkHealth({ | |
| 201 | + params: { owner: "org", integration: slug, name }, | |
| 202 | + query: {}, | |
| 203 | + }); | |
| 204 | + expect(result.status).toBe("unknown"); | |
| 205 | + expect(result.httpStatus).toBeUndefined(); | |
| 206 | + expect(result.detail).toContain("not supported for health checks"); | |
| 207 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 208 | + await step("See why the configured health check could not run", async () => { | |
| 209 | + await visit(page, `/integrations/${slug}`); | |
| 210 | + await page.getByText(result.detail!, { exact: true }).waitFor(); | |
| 211 | + expect( | |
| 212 | + await page.getByText("No health check configured.", { exact: true }).count(), | |
| 213 | + ).toBe(0); | |
| 214 | + }); | |
| 215 | + await step("Edit the unsupported operation to see how to fix it", async () => { | |
| 216 | + const section = page.locator("section").filter({ | |
| 217 | + has: page.getByRole("heading", { name: "Health check", exact: true }), | |
| 218 | + }); | |
| 219 | + await section.getByRole("button", { name: "Edit", exact: true }).click(); | |
| 220 | + await page | |
| 221 | + .getByText("This method is not supported for health checks.", { exact: false }) | |
| 222 | + .waitFor(); | |
| 223 | + expect( | |
| 224 | + await page.getByRole("button", { name: "Save", exact: true }).isEnabled(), | |
| 225 | + ).toBe(false); | |
| 226 | + }); | |
| 227 | + }); | |
| 228 | + }), | |
| 229 | + Effect.gen(function* () { | |
| 230 | + yield* client.connections | |
| 231 | + .remove({ params: { owner: "org", integration: slug, name } }) | |
| 232 | + .pipe(Effect.ignore); | |
| 233 | + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | |
| 234 | + }), | |
| 235 | + ); | |
| 236 | + }), | |
| 237 | + ), | |
| 238 | +); | |
| @@ -70,6 +70,11 @@ const identitySpec = (baseUrl: string): string => · 69 unmodified lines | ||
| 70 | 70 | summary: "Send a message", |
| 71 | 71 | responses: { "201": { description: "created" } }, |
| 72 | 72 | }, |
| 73 | + delete: { | |
| 74 | + operationId: "deleteMessages", | |
| 75 | + summary: "Delete messages", | |
| 76 | + responses: { "204": { description: "deleted" } }, | |
| 77 | + }, | |
| 73 | 78 | }, |
| 74 | 79 | }, |
| 75 | 80 | }); |
| @@ -486,14 +491,13 @@ scenario( · 410 unmodified lines | ||
| 486 | 491 | Effect.gen(function* () { |
| 487 | 492 | yield* registerIdentityIntegration(client, slug, server.url); |
| 488 | 493 | |
| 489 | − // Deliberately declare the DESTRUCTIVE POST as the health check (the | |
| 490 | − // editor warns but allows saving; the runtime is the enforcement). | |
| 494 | + // API configuration must not bypass the runtime's method restriction. | |
| 491 | 495 | const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); |
| 492 | − const post = candidates.find((candidate) => candidate.method === "post"); | |
| 493 | − if (!post) return yield* Effect.die("identity spec exposed no POST candidate"); | |
| 496 | + const mutation = candidates.find((candidate) => candidate.method === "delete"); | |
| 497 | + if (!mutation) return yield* Effect.die("identity spec exposed no DELETE candidate"); | |
| 494 | 498 | yield* client.integrations.healthCheckSet({ |
| 495 | 499 | params: { slug }, |
| 496 | − payload: { spec: { operation: post.operation } }, | |
| 500 | + payload: { spec: { operation: mutation.operation } }, | |
| 497 | 501 | }); |
| 498 | 502 | |
| 499 | 503 | yield* client.connections.create({ |
| @@ -508,13 +512,15 @@ scenario( · 8 unmodified lines | ||
| 508 | 512 | |
| 509 | 513 | // A health check runs unattended and repeatedly with no approval |
| 510 | 514 | // gate, so the probe REFUSES to execute a mutating operation: the |
| 511 | − // result is unknown-with-reason and the upstream never sees a POST. | |
| 515 | + // result is unknown-with-reason and the upstream never sees a DELETE. | |
| 512 | 516 | const result = yield* client.connections.checkHealth({ |
| 513 | 517 | params: { owner: "org", integration: slug, name }, |
| 514 | 518 | query: {}, |
| 515 | 519 | }); |
| 516 | 520 | expect(result.status, "a mutating probe refuses to run").toBe("unknown"); |
| 517 | − expect(result.detail ?? "", "the refusal names the problem").toContain("mutating"); | |
| 521 | + expect(result.detail ?? "", "the refusal names the problem").toContain( | |
| 522 | + "not supported for health checks", | |
| 523 | + ); | |
| 518 | 524 | expect(result.httpStatus, "no request reached the upstream").toBeUndefined(); |
| 519 | 525 | }), |
| 520 | 526 | Effect.gen(function* () { |
| @@ -18,7 +18,11 @@ import { · 17 unmodified lines | ||
| 18 | 18 | useIntegrationIdentity, |
| 19 | 19 | } from "@executor-js/react/plugins/integration-identity"; |
| 20 | 20 | import { Button } from "@executor-js/react/components/button"; |
| 21 | −import { HealthCheckConfigFields } from "@executor-js/react/components/health-check-editor"; | |
| 21 | +import { | |
| 22 | + HealthCheckConfigFields, | |
| 23 | + isUnsupportedHealthCheck, | |
| 24 | + parseHealthCheckArgs, | |
| 25 | +} from "@executor-js/react/components/health-check-editor"; | |
| 22 | 26 | import { |
| 23 | 27 | AuthMethodListEditor, |
| 24 | 28 | useAuthMethodList, |
| @@ -374,6 +378,7 @@ export default function AddOpenApiIntegration(props: { · 349 unmodified lines | ||
| 374 | 378 | const hcMissingRequired = hcRequiredParams.some( |
| 375 | 379 | (p) => (hcArgs[p.name] ?? "").trim().length === 0, |
| 376 | 380 | ); |
| 381 | + const hcParsedArgs = parseHealthCheckArgs(hcArgs); | |
| 377 | 382 | |
| 378 | 383 | const onHcOperationChange = (next: string) => { |
| 379 | 384 | setHcOperation(next); |
| @@ -398,7 +403,10 @@ export default function AddOpenApiIntegration(props: { · 18 unmodified lines | ||
| 398 | 403 | parsedSpecOverrides.ok && |
| 399 | 404 | !slugAlreadyExists && |
| 400 | 405 | (!previewHasNoServers || resolvedBaseUrl.length > 0) && |
| 401 | − !(hcOperation.length > 0 && hcMissingRequired); | |
| 406 | + !( | |
| 407 | + hcOperation.length > 0 && | |
| 408 | + (hcMissingRequired || !hcParsedArgs.ok || isUnsupportedHealthCheck(hcSelected)) | |
| 409 | + ); | |
| 402 | 410 | |
| 403 | 411 | // ---- Handlers ---- |
| 404 | 412 | |
| @@ -499,14 +507,11 @@ export default function AddOpenApiIntegration(props: { · 94 unmodified lines | ||
| 499 | 507 | // the user (re-submitting the form hits the slug-already-exists guard). The |
| 500 | 508 | // check stays editable from the integration's detail page, so on failure we |
| 501 | 509 | // proceed to onComplete regardless and let the user fix it there. |
| 502 | − if (hcOperation.length > 0) { | |
| 510 | + if (hcOperation.length > 0 && hcParsedArgs.ok) { | |
| 503 | 511 | const identity = hcIdentityField.trim(); |
| 504 | − const argEntries = Object.entries(hcArgs) | |
| 505 | − .map(([key, value]) => [key, value.trim()] as const) | |
| 506 | − .filter(([, value]) => value.length > 0); | |
| 507 | 512 | const spec: HealthCheckSpec = { |
| 508 | 513 | operation: hcOperation, |
| 509 | − ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), | |
| 514 | + ...(Object.keys(hcParsedArgs.args).length > 0 ? { args: hcParsedArgs.args } : {}), | |
| 510 | 515 | ...(identity.length > 0 ? { identityField: identity } : {}), |
| 511 | 516 | }; |
| 512 | 517 | // Best-effort: the exit is intentionally ignored so a save failure cannot |
| @@ -53,6 +53,7 @@ import { parse, type ParsedDocument } from "./parse"; · 52 unmodified lines | ||
| 53 | 53 | import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split"; |
| 54 | 54 | import { type OpenapiStore, type StoredOperation } from "./store"; |
| 55 | 55 | import { OperationBinding } from "./types"; |
| 56 | +import { getHealthCheckParameters } from "./health-check-operation"; | |
| 56 | 57 | |
| 57 | 58 | const STRINGIFIED_BODY_CAP = 1024; |
| 58 | 59 | const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); |
| @@ -942,16 +943,15 @@ export const checkHealthOpenApi = (input: { · 883 unmodified lines | ||
| 942 | 943 | } satisfies HealthCheckResult; |
| 943 | 944 | } |
| 944 | 945 | |
| 945 | − // HARD block, not just a ranking hint: a health check runs unattended and | |
| 946 | − // repeatedly, so a mutating operation must never execute through it. The | |
| 947 | − // normal tool path gates these behind approval, and this path has no | |
| 948 | − // approval step. The candidate list labels these "(writes)"; refusing here | |
| 949 | − // is the enforcement. | |
| 950 | − if (REQUIRE_APPROVAL.has(binding.method.toLowerCase())) { | |
| 946 | + // HTTP RPC reads can use POST; the editor warns users before enabling them. | |
| 947 | + if ( | |
| 948 | + REQUIRE_APPROVAL.has(binding.method.toLowerCase()) && | |
| 949 | + binding.method.toLowerCase() !== "post" | |
| 950 | + ) { | |
| 951 | 951 | return { |
| 952 | 952 | status: "unknown", |
| 953 | 953 | checkedAt, |
| 954 | − detail: `Health check operation "${spec.operation}" is a ${binding.method.toUpperCase()} (mutating): pick a read-only operation.`, | |
| 954 | + detail: `Health check operation "${spec.operation}" uses ${binding.method.toUpperCase()} and is not supported for health checks. Pick a read-only operation.`, | |
| 955 | 955 | } satisfies HealthCheckResult; |
| 956 | 956 | } |
| 957 | 957 | |
| @@ -1105,19 +1105,12 @@ export const listHealthCheckCandidatesOpenApi = (input: { · 147 unmodified lines | ||
| 1105 | 1105 | |
| 1106 | 1106 | const candidates = operations.map((op): HealthCheckCandidate => { |
| 1107 | 1107 | const method = op.binding.method.toLowerCase(); |
| 1108 | − const parameters = op.binding.parameters.map((parameter) => ({ | |
| 1109 | − name: parameter.name, | |
| 1110 | − location: parameter.location, | |
| 1111 | − required: parameter.required, | |
| 1112 | − ...(Option.isSome(parameter.description) | |
| 1113 | − ? { description: parameter.description.value } | |
| 1114 | − : {}), | |
| 1115 | − })); | |
| 1108 | + const parameters = getHealthCheckParameters(op.binding); | |
| 1116 | 1109 | const responseFields = responseFieldsByTool.get(op.toolName); |
| 1117 | 1110 | return { |
| 1118 | 1111 | operation: op.toolName, |
| 1119 | 1112 | method, |
| 1120 | − requiredArgCount: op.binding.parameters.filter((parameter) => parameter.required).length, | |
| 1113 | + requiredArgCount: parameters.filter((parameter) => parameter.required).length, | |
| 1121 | 1114 | destructive: REQUIRE_APPROVAL.has(method), |
| 1122 | 1115 | summary: summaries.get(op.toolName) ?? `${method.toUpperCase()} ${op.binding.pathTemplate}`, |
| 1123 | 1116 | ...(parameters.length > 0 ? { parameters } : {}), |
| @@ -976,6 +976,7 @@ export interface StreamedPreviewParameter { · 975 unmodified lines | ||
| 976 | 976 | * add screen's operation list and health-check candidate ranking need, and |
| 977 | 977 | * nothing that scales with schema size. */ |
| 978 | 978 | export interface StreamedPreviewOperation { |
| 979 | + readonly requestBodyRequired?: boolean; | |
| 979 | 980 | readonly operationId: string; |
| 980 | 981 | /** Tool path planned over the full kept operation set, so preview candidates |
| 981 | 982 | * match the names registration will assign. */ |
| @@ -1050,6 +1051,7 @@ export const streamPreviewOperations = ( · 68 unmodified lines | ||
| 1050 | 1051 | }), |
| 1051 | 1052 | ); |
| 1052 | 1053 | metas.push({ |
| 1054 | + requestBodyRequired: extractRequestBody(operation, r)?.required, | |
| 1053 | 1055 | operationId, |
| 1054 | 1056 | method, |
| 1055 | 1057 | pathTemplate: resolvedPathTemplate, |
| @@ -0,0 +1,63 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | +import { dump } from "js-yaml"; | |
| 4 | + | |
| 5 | +import { previewSpecText, previewSpecTextStreaming } from "./preview"; | |
| 6 | + | |
| 7 | +const readOperation = { | |
| 8 | + operationId: "getAccount", | |
| 9 | + parameters: [ | |
| 10 | + { | |
| 11 | + name: "RPC-Version", | |
| 12 | + in: "header", | |
| 13 | + required: true, | |
| 14 | + schema: { type: "string" }, | |
| 15 | + }, | |
| 16 | + ], | |
| 17 | + responses: { "200": { description: "OK" } }, | |
| 18 | +}; | |
| 19 | + | |
| 20 | +describe("health-check operations", () => { | |
| 21 | + it.effect("keeps body requirements and POST risk visible in both preview paths", () => | |
| 22 | + Effect.gen(function* () { | |
| 23 | + for (const required of [true, false]) { | |
| 24 | + const specText = dump({ | |
| 25 | + openapi: "3.0.3", | |
| 26 | + info: { title: "Account RPC API", version: "1" }, | |
| 27 | + servers: [{ url: "https://api.example.test" }], | |
| 28 | + paths: { | |
| 29 | + "/rpc/getAccount": { | |
| 30 | + post: { | |
| 31 | + ...readOperation, | |
| 32 | + requestBody: { | |
| 33 | + required, | |
| 34 | + content: { "application/json": { schema: { type: "object" } } }, | |
| 35 | + }, | |
| 36 | + }, | |
| 37 | + }, | |
| 38 | + "/me": { | |
| 39 | + get: { | |
| 40 | + operationId: "getMe", | |
| 41 | + responses: { "200": { description: "OK" } }, | |
| 42 | + }, | |
| 43 | + }, | |
| 44 | + }, | |
| 45 | + }); | |
| 46 | + const whole = yield* previewSpecText(specText); | |
| 47 | + const streamed = yield* previewSpecTextStreaming(specText); | |
| 48 | + expect(streamed.healthCheckCandidates).toEqual(whole.healthCheckCandidates); | |
| 49 | + expect(whole.healthCheckCandidates[0]?.method).toBe("get"); | |
| 50 | + expect( | |
| 51 | + whole.healthCheckCandidates.find((candidate) => candidate.method === "post"), | |
| 52 | + ).toMatchObject({ | |
| 53 | + destructive: true, | |
| 54 | + requiredArgCount: required ? 2 : 1, | |
| 55 | + parameters: [ | |
| 56 | + { name: "RPC-Version", location: "header", required: true }, | |
| 57 | + { name: "body", location: "body", required }, | |
| 58 | + ], | |
| 59 | + }); | |
| 60 | + } | |
| 61 | + }), | |
| 62 | + ); | |
| 63 | +}); | |
| @@ -0,0 +1,18 @@ | ||
| 1 | +import { Option } from "effect"; | |
| 2 | +import type { HealthCheckCandidateParameter } from "@executor-js/sdk/core"; | |
| 3 | + | |
| 4 | +import type { OperationBinding } from "./types"; | |
| 5 | + | |
| 6 | +export const getHealthCheckParameters = ( | |
| 7 | + operation: Pick<OperationBinding, "parameters" | "requestBody">, | |
| 8 | +): HealthCheckCandidateParameter[] => [ | |
| 9 | + ...operation.parameters.map((parameter) => ({ | |
| 10 | + name: parameter.name, | |
| 11 | + location: parameter.location, | |
| 12 | + required: parameter.required, | |
| 13 | + ...(Option.isSome(parameter.description) ? { description: parameter.description.value } : {}), | |
| 14 | + })), | |
| 15 | + ...(Option.isSome(operation.requestBody) | |
| 16 | + ? [{ name: "body", location: "body", required: operation.requestBody.value.required }] | |
| 17 | + : []), | |
| 18 | +]; | |
| @@ -25,10 +25,10 @@ import { · 24 unmodified lines | ||
| 25 | 25 | type KeepPathItem, |
| 26 | 26 | } from "./split"; |
| 27 | 27 | import { HttpMethod, ServerInfo, type ExtractedOperation, type ExtractionResult } from "./types"; |
| 28 | +import { getHealthCheckParameters } from "./health-check-operation"; | |
| 28 | 29 | |
| 29 | −// Mutating HTTP methods: mirrors `REQUIRE_APPROVAL` in `./invoke` but kept | |
| 30 | −// inline so this browser-safe preview module never pulls in the HTTP execution | |
| 31 | −// path. A health check should be safe to re-run, so these rank last. | |
| 30 | +// Keep potentially mutating methods ranked below ordinary reads. This mirrors | |
| 31 | +// REQUIRE_APPROVAL without importing the HTTP execution path into the browser. | |
| 32 | 32 | const DESTRUCTIVE_METHODS = new Set(["post", "put", "patch", "delete"]); |
| 33 | 33 | |
| 34 | 34 | // Cap on health-check candidate METADATA carried in the preview, so the add |
| @@ -487,18 +487,11 @@ const buildPreviewHealthCheckCandidates = ( · 452 unmodified lines | ||
| 487 | 487 | .map((def): HealthCheckCandidate => { |
| 488 | 488 | const op = def.operation; |
| 489 | 489 | const method = op.method.toLowerCase(); |
| 490 | − const parameters = op.parameters.map((parameter) => ({ | |
| 491 | − name: parameter.name, | |
| 492 | − location: parameter.location, | |
| 493 | − required: parameter.required, | |
| 494 | − ...(Option.isSome(parameter.description) | |
| 495 | − ? { description: parameter.description.value } | |
| 496 | − : {}), | |
| 497 | − })); | |
| 490 | + const parameters = getHealthCheckParameters(op); | |
| 498 | 491 | return { |
| 499 | 492 | operation: def.toolPath, |
| 500 | 493 | method, |
| 501 | − requiredArgCount: op.parameters.filter((parameter) => parameter.required).length, | |
| 494 | + requiredArgCount: parameters.filter((parameter) => parameter.required).length, | |
| 502 | 495 | destructive: DESTRUCTIVE_METHODS.has(method), |
| 503 | 496 | summary: |
| 504 | 497 | Option.getOrUndefined(op.summary) ?? |
| @@ -590,13 +583,19 @@ export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (s · 85 unmodified lines | ||
| 590 | 583 | |
| 591 | 584 | const streamedCandidate = (op: StreamedPreviewOperation): HealthCheckCandidate => { |
| 592 | 585 | const method = op.method.toLowerCase(); |
| 586 | + const parameters = [ | |
| 587 | + ...op.parameters, | |
| 588 | + ...(op.requestBodyRequired === undefined | |
| 589 | + ? [] | |
| 590 | + : [{ name: "body", location: "body", required: op.requestBodyRequired }]), | |
| 591 | + ]; | |
| 593 | 592 | return { |
| 594 | 593 | operation: op.toolPath, |
| 595 | 594 | method, |
| 596 | − requiredArgCount: op.parameters.filter((parameter) => parameter.required).length, | |
| 595 | + requiredArgCount: parameters.filter((parameter) => parameter.required).length, | |
| 597 | 596 | destructive: DESTRUCTIVE_METHODS.has(method), |
| 598 | 597 | summary: op.summary ?? op.description ?? `${method.toUpperCase()} ${op.pathTemplate}`, |
| 599 | − ...(op.parameters.length > 0 ? { parameters: op.parameters } : {}), | |
| 598 | + ...(parameters.length > 0 ? { parameters } : {}), | |
| 600 | 599 | }; |
| 601 | 600 | }; |
| 602 | 601 | |
| @@ -175,7 +175,7 @@ function AccountRow(props: { · 174 unmodified lines | ||
| 175 | 175 | // below, because the remediation is a console visit, not a reconnect. |
| 176 | 176 | const misconfigured = status === "misconfigured"; |
| 177 | 177 | const needsHealthAttention = status === "expired" || status === "degraded"; |
| 178 | − const healthDetail = needsHealthAttention ? probe?.detail : undefined; | |
| 178 | + const healthDetail = needsHealthAttention || status === "unknown" ? probe?.detail : undefined; | |
| 179 | 179 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; |
| 180 | 180 | |
| 181 | 181 | const handleCheck = async () => { |
| @@ -204,7 +204,7 @@ function AccountRow(props: { · 22 unmodified lines | ||
| 204 | 204 | } else if (exit.value.status === "degraded") { |
| 205 | 205 | toast.warning(exit.value.detail ?? "Connection check returned an error"); |
| 206 | 206 | } else { |
| 207 | − toast.message("No health check is configured for this integration"); | |
| 207 | + toast.message(exit.value.detail ?? "Health check did not run"); | |
| 208 | 208 | } |
| 209 | 209 | }; |
| 210 | 210 | |
| @@ -22,9 +22,11 @@ import { · 21 unmodified lines | ||
| 22 | 22 | import { healthCheckWriteKeys } from "../api/reactivity-keys"; |
| 23 | 23 | import { messageFromExit } from "../api/error-reporting"; |
| 24 | 24 | import { HEALTH_STATUS_LABEL, HEALTH_TEXT_CLASS } from "../lib/health-display"; |
| 25 | +import { formatHealthCheckArgs, parseHealthCheckArgs } from "../lib/health-check-args"; | |
| 25 | 26 | import { Button } from "./button"; |
| 26 | 27 | import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; |
| 27 | 28 | import { Input } from "./input"; |
| 29 | +import { Textarea } from "./textarea"; | |
| 28 | 30 | import { Label } from "./label"; |
| 29 | 31 | import { NativeSelect, NativeSelectOption } from "./native-select"; |
| 30 | 32 | import { |
| @@ -70,11 +72,14 @@ export interface HealthCheckLivePreview { · 39 unmodified lines | ||
| 70 | 72 | }>; |
| 71 | 73 | } |
| 72 | 74 | |
| 73 | −/** "GET /users/me" style label for a candidate, with a writes marker so a | |
| 74 | − * mutating operation picked as a health check reads as the hazard it is. */ | |
| 75 | +export const isUnsupportedHealthCheck = (candidate: HealthCheckCandidate | null): boolean => | |
| 76 | + candidate?.destructive === true && candidate.method.toLowerCase() !== "post"; | |
| 77 | + | |
| 78 | +/** POST may be a read operation in an HTTP RPC API, but needs a warning. */ | |
| 75 | 79 | const candidateLabel = (candidate: HealthCheckCandidate): string => { |
| 76 | 80 | const head = `${candidate.method.toUpperCase()} ${candidate.operation}`; |
| 77 | − return candidate.destructive ? `${head} (writes)` : head; | |
| 81 | + if (candidate.method.toLowerCase() === "post") return `${head} (may change data)`; | |
| 82 | + return isUnsupportedHealthCheck(candidate) ? `${head} (unsupported for health checks)` : head; | |
| 78 | 83 | }; |
| 79 | 84 | |
| 80 | 85 | /** The summary line for the configured spec: the operation, prefixed with its |
| @@ -137,8 +142,8 @@ function HealthCheckConfigFields(props: { · 56 unmodified lines | ||
| 137 | 142 | [selected], |
| 138 | 143 | ); |
| 139 | 144 | |
| 140 | − const requiredParams = useMemo( | |
| 141 | − () => (selected?.parameters ?? []).filter((p) => p.required), | |
| 145 | + const pinnedParams = useMemo( | |
| 146 | + () => (selected?.parameters ?? []).filter((p) => p.required || p.location === "body"), | |
| 142 | 147 | [selected], |
| 143 | 148 | ); |
| 144 | 149 | |
| @@ -158,40 +163,64 @@ function HealthCheckConfigFields(props: { · 13 unmodified lines | ||
| 158 | 163 | {selected?.summary ? ( |
| 159 | 164 | <p className="text-xs text-muted-foreground">{selected.summary}</p> |
| 160 | 165 | ) : null} |
| 161 | − {selected?.destructive ? ( | |
| 166 | + {selected?.method.toLowerCase() === "post" ? ( | |
| 167 | + <p role="alert" className="text-xs text-amber-600 dark:text-amber-400"> | |
| 168 | + POST requests can change data. Health checks run automatically and repeatedly; choose an | |
| 169 | + operation that only reads data. | |
| 170 | + </p> | |
| 171 | + ) : isUnsupportedHealthCheck(selected) ? ( | |
| 162 | 172 | <p className="text-xs text-destructive"> |
| 163 | − This operation writes data. Prefer a read-only (GET) operation for a health check. | |
| 173 | + This method is not supported for health checks. Pick a read-only operation. | |
| 164 | 174 | </p> |
| 165 | 175 | ) : null} |
| 166 | 176 | </div> |
| 167 | 177 | |
| 168 | − {requiredParams.length > 0 ? ( | |
| 178 | + {pinnedParams.length > 0 ? ( | |
| 169 | 179 | <div className="space-y-3"> |
| 170 | 180 | <div className="space-y-0.5"> |
| 171 | − <p className="text-sm font-medium">Required arguments</p> | |
| 181 | + <p className="text-sm font-medium">Pinned arguments</p> | |
| 172 | 182 | <p className="text-xs text-muted-foreground"> |
| 173 | 183 | Pinned into every probe. An identity endpoint often needs a fixed value here (for |
| 174 | 184 | example <span className="font-mono">resourceName</span> ={" "} |
| 175 | 185 | <span className="font-mono">people/me</span>). |
| 176 | 186 | </p> |
| 177 | 187 | </div> |
| 178 | − {requiredParams.map((param) => ( | |
| 188 | + {pinnedParams.map((param) => ( | |
| 179 | 189 | <div key={param.name} className="space-y-1.5"> |
| 180 | 190 | <Label htmlFor={`${idPrefix}-arg-${param.name}`}> |
| 181 | 191 | {param.name} |
| 182 | 192 | <span className="ml-1.5 text-xs font-normal text-muted-foreground"> |
| 183 | − ({param.location}) | |
| 193 | + ({param.location} | |
| 194 | + {param.required ? ", required" : ", optional"}) | |
| 184 | 195 | </span> |
| 185 | 196 | </Label> |
| 186 | − <Input | |
| 187 | − id={`${idPrefix}-arg-${param.name}`} | |
| 188 | − value={args[param.name] ?? ""} | |
| 189 | − onChange={(e: React.ChangeEvent<HTMLInputElement>) => | |
| 190 | − props.onArgChange(param.name, e.target.value) | |
| 191 | − } | |
| 192 | − placeholder={param.description ?? `Value for ${param.name}`} | |
| 193 | − disabled={disabled} | |
| 194 | − /> | |
| 197 | + {param.location === "body" ? ( | |
| 198 | + <> | |
| 199 | + <Textarea | |
| 200 | + id={`${idPrefix}-arg-${param.name}`} | |
| 201 | + value={args[param.name] ?? ""} | |
| 202 | + onChange={(e) => props.onArgChange(param.name, e.target.value)} | |
| 203 | + placeholder='{"pageSize": 1}' | |
| 204 | + aria-label="Request body (JSON)" | |
| 205 | + aria-invalid={!parseHealthCheckArgs(args).ok} | |
| 206 | + disabled={disabled} | |
| 207 | + className="font-mono text-xs" | |
| 208 | + /> | |
| 209 | + {!parseHealthCheckArgs(args).ok ? ( | |
| 210 | + <p className="text-xs text-destructive">Enter a valid JSON request body.</p> | |
| 211 | + ) : null} | |
| 212 | + </> | |
| 213 | + ) : ( | |
| 214 | + <Input | |
| 215 | + id={`${idPrefix}-arg-${param.name}`} | |
| 216 | + value={args[param.name] ?? ""} | |
| 217 | + onChange={(e: React.ChangeEvent<HTMLInputElement>) => | |
| 218 | + props.onArgChange(param.name, e.target.value) | |
| 219 | + } | |
| 220 | + placeholder={param.description ?? `Value for ${param.name}`} | |
| 221 | + disabled={disabled} | |
| 222 | + /> | |
| 223 | + )} | |
| 195 | 224 | {param.description ? ( |
| 196 | 225 | <p className="text-xs text-muted-foreground">{param.description}</p> |
| 197 | 226 | ) : null} |
| @@ -253,12 +282,11 @@ function HealthCheckLivePreviewBlock(props: { · 55 unmodified lines | ||
| 253 | 282 | const slug = (template || preview.templates[0]?.template) as AuthTemplateSlug | undefined; |
| 254 | 283 | if (slug === undefined) return; |
| 255 | 284 | const identity = identityField.trim(); |
| 256 | − const argEntries = Object.entries(args) | |
| 257 | − .map(([key, v]) => [key, v.trim()] as const) | |
| 258 | − .filter(([, v]) => v.length > 0); | |
| 285 | + const parsedArgs = parseHealthCheckArgs(args); | |
| 286 | + if (!parsedArgs.ok) return; | |
| 259 | 287 | const spec: HealthCheckSpec = { |
| 260 | 288 | operation, |
| 261 | − ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), | |
| 289 | + ...(Object.keys(parsedArgs.args).length > 0 ? { args: parsedArgs.args } : {}), | |
| 262 | 290 | ...(identity.length > 0 ? { identityField: identity } : {}), |
| 263 | 291 | }; |
| 264 | 292 | setRunning(true); |
| @@ -382,7 +410,7 @@ function HealthCheckEditorSheet(props: { · 117 unmodified lines | ||
| 382 | 410 | const [operation, setOperation] = useState(""); |
| 383 | 411 | const [identityField, setIdentityField] = useState(""); |
| 384 | 412 | // Pinned argument values keyed by parameter name; stored as strings (the form |
| 385 | − // input value) and trimmed/dropped when empty at save. | |
| 413 | + // input value); JSON bodies are parsed before save and preview. | |
| 386 | 414 | const [args, setArgs] = useState<Record<string, string>>({}); |
| 387 | 415 | const [saving, setSaving] = useState(false); |
| 388 | 416 | |
| @@ -391,16 +419,7 @@ function HealthCheckEditorSheet(props: { · 2 unmodified lines | ||
| 391 | 419 | if (!open) return; |
| 392 | 420 | setOperation(spec?.operation ?? candidates[0]?.operation ?? ""); |
| 393 | 421 | setIdentityField(spec?.identityField ?? ""); |
| 394 | − setArgs( | |
| 395 | − spec?.args | |
| 396 | − ? Object.fromEntries( | |
| 397 | − Object.entries(spec.args).map(([key, value]) => [ | |
| 398 | − key, | |
| 399 | − value == null ? "" : String(value), | |
| 400 | − ]), | |
| 401 | − ) | |
| 402 | − : {}, | |
| 403 | − ); | |
| 422 | + setArgs(formatHealthCheckArgs(spec?.args)); | |
| 404 | 423 | }, [open, spec, candidates]); |
| 405 | 424 | |
| 406 | 425 | const selected = useMemo( |
| @@ -413,6 +432,7 @@ function HealthCheckEditorSheet(props: { · 6 unmodified lines | ||
| 413 | 432 | [selected], |
| 414 | 433 | ); |
| 415 | 434 | const missingRequired = requiredParams.some((p) => (args[p.name] ?? "").trim().length === 0); |
| 435 | + const parsedArgs = parseHealthCheckArgs(args); | |
| 416 | 436 | |
| 417 | 437 | const onOperationChange = (next: string) => { |
| 418 | 438 | setOperation(next); |
| @@ -426,15 +446,12 @@ function HealthCheckEditorSheet(props: { · 7 unmodified lines | ||
| 426 | 446 | setArgs((prev) => ({ ...prev, [name]: value })); |
| 427 | 447 | |
| 428 | 448 | const handleSave = async () => { |
| 429 | − if (operation.length === 0) return; | |
| 449 | + if (operation.length === 0 || !parsedArgs.ok) return; | |
| 430 | 450 | setSaving(true); |
| 431 | 451 | const identity = identityField.trim(); |
| 432 | − const argEntries = Object.entries(args) | |
| 433 | − .map(([key, value]) => [key, value.trim()] as const) | |
| 434 | − .filter(([, value]) => value.length > 0); | |
| 435 | 452 | const nextSpec: HealthCheckSpec = { |
| 436 | 453 | operation, |
| 437 | − ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), | |
| 454 | + ...(Object.keys(parsedArgs.args).length > 0 ? { args: parsedArgs.args } : {}), | |
| 438 | 455 | ...(identity.length > 0 ? { identityField: identity } : {}), |
| 439 | 456 | }; |
| 440 | 457 | const exit = await doSet({ |
| @@ -503,7 +520,9 @@ function HealthCheckEditorSheet(props: { · 62 unmodified lines | ||
| 503 | 520 | operation={operation} |
| 504 | 521 | identityField={identityField} |
| 505 | 522 | args={args} |
| 506 | − disabled={saving} | |
| 523 | + disabled={ | |
| 524 | + saving || !parsedArgs.ok || missingRequired || isUnsupportedHealthCheck(selected) | |
| 525 | + } | |
| 507 | 526 | /> |
| 508 | 527 | ) : null} |
| 509 | 528 | </div> |
| @@ -511,7 +530,13 @@ function HealthCheckEditorSheet(props: { · 1 unmodified line | ||
| 511 | 530 | <SheetFooter> |
| 512 | 531 | <Button |
| 513 | 532 | onClick={() => void handleSave()} |
| 514 | − disabled={saving || operation.length === 0 || missingRequired} | |
| 533 | + disabled={ | |
| 534 | + saving || | |
| 535 | + operation.length === 0 || | |
| 536 | + missingRequired || | |
| 537 | + !parsedArgs.ok || | |
| 538 | + isUnsupportedHealthCheck(selected) | |
| 539 | + } | |
| 515 | 540 | > |
| 516 | 541 | {saving ? "Saving..." : "Save"} |
| 517 | 542 | </Button> |
| @@ -597,3 +622,4 @@ export function HealthCheckEditor(props: { · 79 unmodified lines | ||
| 597 | 622 | // Re-exported so the add-integration screen can compose the same operation + |
| 598 | 623 | // identity form without the sheet/live-preview chrome. |
| 599 | 624 | export { HealthCheckConfigFields }; |
| 625 | +export { parseHealthCheckArgs } from "../lib/health-check-args"; | |
| @@ -0,0 +1,31 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { formatHealthCheckArgs, parseHealthCheckArgs } from "./health-check-args"; | |
| 4 | + | |
| 5 | +describe("health-check request bodies", () => { | |
| 6 | + it("preserves nested values through editing and saving", () => { | |
| 7 | + const args = { | |
| 8 | + "Connect-Protocol-Version": "1", | |
| 9 | + body: { | |
| 10 | + pageSize: 1, | |
| 11 | + status: ["queued", "running", "finished", "failed", "cancelled"], | |
| 12 | + filter: { enabled: false }, | |
| 13 | + }, | |
| 14 | + }; | |
| 15 | + expect(parseHealthCheckArgs(formatHealthCheckArgs(args))).toEqual({ ok: true, args }); | |
| 16 | + for (const body of [null, false, 0, "plain text", [1, 2]]) { | |
| 17 | + expect(parseHealthCheckArgs(formatHealthCheckArgs({ body }))).toEqual({ | |
| 18 | + ok: true, | |
| 19 | + args: { body }, | |
| 20 | + }); | |
| 21 | + } | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it("rejects invalid JSON and drops a cleared optional body", () => { | |
| 25 | + expect(parseHealthCheckArgs({ body: '{"pageSize":' })).toEqual({ ok: false }); | |
| 26 | + expect(parseHealthCheckArgs({ body: " ", id: " me " })).toEqual({ | |
| 27 | + ok: true, | |
| 28 | + args: { id: "me" }, | |
| 29 | + }); | |
| 30 | + }); | |
| 31 | +}); | |
| @@ -0,0 +1,30 @@ | ||
| 1 | +import { Option, Schema } from "effect"; | |
| 2 | + | |
| 3 | +const decodeBody = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); | |
| 4 | + | |
| 5 | +export const formatHealthCheckArgs = ( | |
| 6 | + args: Readonly<Record<string, unknown>> | undefined, | |
| 7 | +): Record<string, string> => | |
| 8 | + Object.fromEntries( | |
| 9 | + Object.entries(args ?? {}).map(([key, value]) => [ | |
| 10 | + key, | |
| 11 | + key === "body" ? (JSON.stringify(value) ?? "") : value == null ? "" : String(value), | |
| 12 | + ]), | |
| 13 | + ); | |
| 14 | + | |
| 15 | +export const parseHealthCheckArgs = ( | |
| 16 | + draft: Readonly<Record<string, string>>, | |
| 17 | +): { readonly ok: true; readonly args: Record<string, unknown> } | { readonly ok: false } => { | |
| 18 | + const args: Record<string, unknown> = {}; | |
| 19 | + for (const [name, value] of Object.entries(draft)) { | |
| 20 | + if (value.trim().length === 0) continue; | |
| 21 | + if (name === "body") { | |
| 22 | + const body = decodeBody(value); | |
| 23 | + if (Option.isNone(body)) return { ok: false }; | |
| 24 | + args[name] = body.value; | |
| 25 | + } else { | |
| 26 | + args[name] = value.trim(); | |
| 27 | + } | |
| 28 | + } | |
| 29 | + return { ok: true, args }; | |
| 30 | +}; | |