* 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 | summary: "Send a message", | 70 | summary: "Send a message", |
| 71 | responses: { "201": { description: "created" } }, | 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 | Effect.gen(function* () { | 491 | Effect.gen(function* () { |
| 487 | yield* registerIdentityIntegration(client, slug, server.url); | 492 | yield* registerIdentityIntegration(client, slug, server.url); |
| 488 | 493 | ||
| 489 | // Deliberately declare the DESTRUCTIVE POST as the health check (the | 494 | // API configuration must not bypass the runtime's method restriction. |
| 490 | // editor warns but allows saving; the runtime is the enforcement). | ||
| 491 | const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); | 495 | const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); |
| 492 | const post = candidates.find((candidate) => candidate.method === "post"); | 496 | const mutation = candidates.find((candidate) => candidate.method === "delete"); |
| 493 | if (!post) return yield* Effect.die("identity spec exposed no POST candidate"); | 497 | if (!mutation) return yield* Effect.die("identity spec exposed no DELETE candidate"); |
| 494 | yield* client.integrations.healthCheckSet({ | 498 | yield* client.integrations.healthCheckSet({ |
| 495 | params: { slug }, | 499 | params: { slug }, |
| 496 | payload: { spec: { operation: post.operation } }, | 500 | payload: { spec: { operation: mutation.operation } }, |
| 497 | }); | 501 | }); |
| 498 | 502 | ||
| 499 | yield* client.connections.create({ | 503 | yield* client.connections.create({ |
| @@ -508,13 +512,15 @@ scenario( · 8 unmodified lines | |||
| 508 | 512 | ||
| 509 | // A health check runs unattended and repeatedly with no approval | 513 | // A health check runs unattended and repeatedly with no approval |
| 510 | // gate, so the probe REFUSES to execute a mutating operation: the | 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 | const result = yield* client.connections.checkHealth({ | 516 | const result = yield* client.connections.checkHealth({ |
| 513 | params: { owner: "org", integration: slug, name }, | 517 | params: { owner: "org", integration: slug, name }, |
| 514 | query: {}, | 518 | query: {}, |
| 515 | }); | 519 | }); |
| 516 | expect(result.status, "a mutating probe refuses to run").toBe("unknown"); | 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 | expect(result.httpStatus, "no request reached the upstream").toBeUndefined(); | 524 | expect(result.httpStatus, "no request reached the upstream").toBeUndefined(); |
| 519 | }), | 525 | }), |
| 520 | Effect.gen(function* () { | 526 | Effect.gen(function* () { |
| @@ -18,7 +18,11 @@ import { · 17 unmodified lines | |||
| 18 | useIntegrationIdentity, | 18 | useIntegrationIdentity, |
| 19 | } from "@executor-js/react/plugins/integration-identity"; | 19 | } from "@executor-js/react/plugins/integration-identity"; |
| 20 | import { Button } from "@executor-js/react/components/button"; | 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 | import { | 26 | import { |
| 23 | AuthMethodListEditor, | 27 | AuthMethodListEditor, |
| 24 | useAuthMethodList, | 28 | useAuthMethodList, |
| @@ -374,6 +378,7 @@ export default function AddOpenApiIntegration(props: { · 349 unmodified lines | |||
| 374 | const hcMissingRequired = hcRequiredParams.some( | 378 | const hcMissingRequired = hcRequiredParams.some( |
| 375 | (p) => (hcArgs[p.name] ?? "").trim().length === 0, | 379 | (p) => (hcArgs[p.name] ?? "").trim().length === 0, |
| 376 | ); | 380 | ); |
| 381 | const hcParsedArgs = parseHealthCheckArgs(hcArgs); | ||
| 377 | 382 | ||
| 378 | const onHcOperationChange = (next: string) => { | 383 | const onHcOperationChange = (next: string) => { |
| 379 | setHcOperation(next); | 384 | setHcOperation(next); |
| @@ -398,7 +403,10 @@ export default function AddOpenApiIntegration(props: { · 18 unmodified lines | |||
| 398 | parsedSpecOverrides.ok && | 403 | parsedSpecOverrides.ok && |
| 399 | !slugAlreadyExists && | 404 | !slugAlreadyExists && |
| 400 | (!previewHasNoServers || resolvedBaseUrl.length > 0) && | 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 | // ---- Handlers ---- | 411 | // ---- Handlers ---- |
| 404 | 412 | ||
| @@ -499,14 +507,11 @@ export default function AddOpenApiIntegration(props: { · 94 unmodified lines | |||
| 499 | // the user (re-submitting the form hits the slug-already-exists guard). The | 507 | // the user (re-submitting the form hits the slug-already-exists guard). The |
| 500 | // check stays editable from the integration's detail page, so on failure we | 508 | // check stays editable from the integration's detail page, so on failure we |
| 501 | // proceed to onComplete regardless and let the user fix it there. | 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 | const identity = hcIdentityField.trim(); | 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 | const spec: HealthCheckSpec = { | 512 | const spec: HealthCheckSpec = { |
| 508 | operation: hcOperation, | 513 | operation: hcOperation, |
| 509 | ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), | 514 | ...(Object.keys(hcParsedArgs.args).length > 0 ? { args: hcParsedArgs.args } : {}), |
| 510 | ...(identity.length > 0 ? { identityField: identity } : {}), | 515 | ...(identity.length > 0 ? { identityField: identity } : {}), |
| 511 | }; | 516 | }; |
| 512 | // Best-effort: the exit is intentionally ignored so a save failure cannot | 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 | import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split"; | 53 | import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split"; |
| 54 | import { type OpenapiStore, type StoredOperation } from "./store"; | 54 | import { type OpenapiStore, type StoredOperation } from "./store"; |
| 55 | import { OperationBinding } from "./types"; | 55 | import { OperationBinding } from "./types"; |
| 56 | import { getHealthCheckParameters } from "./health-check-operation"; | ||
| 56 | 57 | ||
| 57 | const STRINGIFIED_BODY_CAP = 1024; | 58 | const STRINGIFIED_BODY_CAP = 1024; |
| 58 | const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); | 59 | const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); |
| @@ -942,16 +943,15 @@ export const checkHealthOpenApi = (input: { · 883 unmodified lines | |||
| 942 | } satisfies HealthCheckResult; | 943 | } satisfies HealthCheckResult; |
| 943 | } | 944 | } |
| 944 | 945 | ||
| 945 | // HARD block, not just a ranking hint: a health check runs unattended and | 946 | // HTTP RPC reads can use POST; the editor warns users before enabling them. |
| 946 | // repeatedly, so a mutating operation must never execute through it. The | 947 | if ( |
| 947 | // normal tool path gates these behind approval, and this path has no | 948 | REQUIRE_APPROVAL.has(binding.method.toLowerCase()) && |
| 948 | // approval step. The candidate list labels these "(writes)"; refusing here | 949 | binding.method.toLowerCase() !== "post" |
| 949 | // is the enforcement. | 950 | ) { |
| 950 | if (REQUIRE_APPROVAL.has(binding.method.toLowerCase())) { | ||
| 951 | return { | 951 | return { |
| 952 | status: "unknown", | 952 | status: "unknown", |
| 953 | checkedAt, | 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 | } satisfies HealthCheckResult; | 955 | } satisfies HealthCheckResult; |
| 956 | } | 956 | } |
| 957 | 957 | ||
| @@ -1105,19 +1105,12 @@ export const listHealthCheckCandidatesOpenApi = (input: { · 147 unmodified lines | |||
| 1105 | 1105 | ||
| 1106 | const candidates = operations.map((op): HealthCheckCandidate => { | 1106 | const candidates = operations.map((op): HealthCheckCandidate => { |
| 1107 | const method = op.binding.method.toLowerCase(); | 1107 | const method = op.binding.method.toLowerCase(); |
| 1108 | const parameters = op.binding.parameters.map((parameter) => ({ | 1108 | const parameters = getHealthCheckParameters(op.binding); |
| 1109 | name: parameter.name, | ||
| 1110 | location: parameter.location, | ||
| 1111 | required: parameter.required, | ||
| 1112 | ...(Option.isSome(parameter.description) | ||
| 1113 | ? { description: parameter.description.value } | ||
| 1114 | : {}), | ||
| 1115 | })); | ||
| 1116 | const responseFields = responseFieldsByTool.get(op.toolName); | 1109 | const responseFields = responseFieldsByTool.get(op.toolName); |
| 1117 | return { | 1110 | return { |
| 1118 | operation: op.toolName, | 1111 | operation: op.toolName, |
| 1119 | method, | 1112 | method, |
| 1120 | requiredArgCount: op.binding.parameters.filter((parameter) => parameter.required).length, | 1113 | requiredArgCount: parameters.filter((parameter) => parameter.required).length, |
| 1121 | destructive: REQUIRE_APPROVAL.has(method), | 1114 | destructive: REQUIRE_APPROVAL.has(method), |
| 1122 | summary: summaries.get(op.toolName) ?? `${method.toUpperCase()} ${op.binding.pathTemplate}`, | 1115 | summary: summaries.get(op.toolName) ?? `${method.toUpperCase()} ${op.binding.pathTemplate}`, |
| 1123 | ...(parameters.length > 0 ? { parameters } : {}), | 1116 | ...(parameters.length > 0 ? { parameters } : {}), |
| @@ -976,6 +976,7 @@ export interface StreamedPreviewParameter { · 975 unmodified lines | |||
| 976 | * add screen's operation list and health-check candidate ranking need, and | 976 | * add screen's operation list and health-check candidate ranking need, and |
| 977 | * nothing that scales with schema size. */ | 977 | * nothing that scales with schema size. */ |
| 978 | export interface StreamedPreviewOperation { | 978 | export interface StreamedPreviewOperation { |
| 979 | readonly requestBodyRequired?: boolean; | ||
| 979 | readonly operationId: string; | 980 | readonly operationId: string; |
| 980 | /** Tool path planned over the full kept operation set, so preview candidates | 981 | /** Tool path planned over the full kept operation set, so preview candidates |
| 981 | * match the names registration will assign. */ | 982 | * match the names registration will assign. */ |
| @@ -1050,6 +1051,7 @@ export const streamPreviewOperations = ( · 68 unmodified lines | |||
| 1050 | }), | 1051 | }), |
| 1051 | ); | 1052 | ); |
| 1052 | metas.push({ | 1053 | metas.push({ |
| 1054 | requestBodyRequired: extractRequestBody(operation, r)?.required, | ||
| 1053 | operationId, | 1055 | operationId, |
| 1054 | method, | 1056 | method, |
| 1055 | pathTemplate: resolvedPathTemplate, | 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 | type KeepPathItem, | 25 | type KeepPathItem, |
| 26 | } from "./split"; | 26 | } from "./split"; |
| 27 | import { HttpMethod, ServerInfo, type ExtractedOperation, type ExtractionResult } from "./types"; | 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 | // Keep potentially mutating methods ranked below ordinary reads. This mirrors |
| 30 | // inline so this browser-safe preview module never pulls in the HTTP execution | 31 | // REQUIRE_APPROVAL without importing the HTTP execution path into the browser. |
| 31 | // path. A health check should be safe to re-run, so these rank last. | ||
| 32 | const DESTRUCTIVE_METHODS = new Set(["post", "put", "patch", "delete"]); | 32 | const DESTRUCTIVE_METHODS = new Set(["post", "put", "patch", "delete"]); |
| 33 | 33 | ||
| 34 | // Cap on health-check candidate METADATA carried in the preview, so the add | 34 | // Cap on health-check candidate METADATA carried in the preview, so the add |
| @@ -487,18 +487,11 @@ const buildPreviewHealthCheckCandidates = ( · 452 unmodified lines | |||
| 487 | .map((def): HealthCheckCandidate => { | 487 | .map((def): HealthCheckCandidate => { |
| 488 | const op = def.operation; | 488 | const op = def.operation; |
| 489 | const method = op.method.toLowerCase(); | 489 | const method = op.method.toLowerCase(); |
| 490 | const parameters = op.parameters.map((parameter) => ({ | 490 | const parameters = getHealthCheckParameters(op); |
| 491 | name: parameter.name, | ||
| 492 | location: parameter.location, | ||
| 493 | required: parameter.required, | ||
| 494 | ...(Option.isSome(parameter.description) | ||
| 495 | ? { description: parameter.description.value } | ||
| 496 | : {}), | ||
| 497 | })); | ||
| 498 | return { | 491 | return { |
| 499 | operation: def.toolPath, | 492 | operation: def.toolPath, |
| 500 | method, | 493 | method, |
| 501 | requiredArgCount: op.parameters.filter((parameter) => parameter.required).length, | 494 | requiredArgCount: parameters.filter((parameter) => parameter.required).length, |
| 502 | destructive: DESTRUCTIVE_METHODS.has(method), | 495 | destructive: DESTRUCTIVE_METHODS.has(method), |
| 503 | summary: | 496 | summary: |
| 504 | Option.getOrUndefined(op.summary) ?? | 497 | Option.getOrUndefined(op.summary) ?? |
| @@ -590,13 +583,19 @@ export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (s · 85 unmodified lines | |||
| 590 | 583 | ||
| 591 | const streamedCandidate = (op: StreamedPreviewOperation): HealthCheckCandidate => { | 584 | const streamedCandidate = (op: StreamedPreviewOperation): HealthCheckCandidate => { |
| 592 | const method = op.method.toLowerCase(); | 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 | return { | 592 | return { |
| 594 | operation: op.toolPath, | 593 | operation: op.toolPath, |
| 595 | method, | 594 | method, |
| 596 | requiredArgCount: op.parameters.filter((parameter) => parameter.required).length, | 595 | requiredArgCount: parameters.filter((parameter) => parameter.required).length, |
| 597 | destructive: DESTRUCTIVE_METHODS.has(method), | 596 | destructive: DESTRUCTIVE_METHODS.has(method), |
| 598 | summary: op.summary ?? op.description ?? `${method.toUpperCase()} ${op.pathTemplate}`, | 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 | // below, because the remediation is a console visit, not a reconnect. | 175 | // below, because the remediation is a console visit, not a reconnect. |
| 176 | const misconfigured = status === "misconfigured"; | 176 | const misconfigured = status === "misconfigured"; |
| 177 | const needsHealthAttention = status === "expired" || status === "degraded"; | 177 | const needsHealthAttention = status === "expired" || status === "degraded"; |
| 178 | const healthDetail = needsHealthAttention ? probe?.detail : undefined; | 178 | const healthDetail = needsHealthAttention || status === "unknown" ? probe?.detail : undefined; |
| 179 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; | 179 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; |
| 180 | 180 | ||
| 181 | const handleCheck = async () => { | 181 | const handleCheck = async () => { |
| @@ -204,7 +204,7 @@ function AccountRow(props: { · 22 unmodified lines | |||
| 204 | } else if (exit.value.status === "degraded") { | 204 | } else if (exit.value.status === "degraded") { |
| 205 | toast.warning(exit.value.detail ?? "Connection check returned an error"); | 205 | toast.warning(exit.value.detail ?? "Connection check returned an error"); |
| 206 | } else { | 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 | import { healthCheckWriteKeys } from "../api/reactivity-keys"; | 22 | import { healthCheckWriteKeys } from "../api/reactivity-keys"; |
| 23 | import { messageFromExit } from "../api/error-reporting"; | 23 | import { messageFromExit } from "../api/error-reporting"; |
| 24 | import { HEALTH_STATUS_LABEL, HEALTH_TEXT_CLASS } from "../lib/health-display"; | 24 | import { HEALTH_STATUS_LABEL, HEALTH_TEXT_CLASS } from "../lib/health-display"; |
| 25 | import { formatHealthCheckArgs, parseHealthCheckArgs } from "../lib/health-check-args"; | ||
| 25 | import { Button } from "./button"; | 26 | import { Button } from "./button"; |
| 26 | import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; | 27 | import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; |
| 27 | import { Input } from "./input"; | 28 | import { Input } from "./input"; |
| 29 | import { Textarea } from "./textarea"; | ||
| 28 | import { Label } from "./label"; | 30 | import { Label } from "./label"; |
| 29 | import { NativeSelect, NativeSelectOption } from "./native-select"; | 31 | import { NativeSelect, NativeSelectOption } from "./native-select"; |
| 30 | import { | 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 | 75 | export const isUnsupportedHealthCheck = (candidate: HealthCheckCandidate | null): boolean => |
| 74 | * mutating operation picked as a health check reads as the hazard it is. */ | 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 | const candidateLabel = (candidate: HealthCheckCandidate): string => { | 79 | const candidateLabel = (candidate: HealthCheckCandidate): string => { |
| 76 | const head = `${candidate.method.toUpperCase()} ${candidate.operation}`; | 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 | /** The summary line for the configured spec: the operation, prefixed with its | 85 | /** The summary line for the configured spec: the operation, prefixed with its |
| @@ -137,8 +142,8 @@ function HealthCheckConfigFields(props: { · 56 unmodified lines | |||
| 137 | [selected], | 142 | [selected], |
| 138 | ); | 143 | ); |
| 139 | 144 | ||
| 140 | const requiredParams = useMemo( | 145 | const pinnedParams = useMemo( |
| 141 | () => (selected?.parameters ?? []).filter((p) => p.required), | 146 | () => (selected?.parameters ?? []).filter((p) => p.required || p.location === "body"), |
| 142 | [selected], | 147 | [selected], |
| 143 | ); | 148 | ); |
| 144 | 149 | ||
| @@ -158,40 +163,64 @@ function HealthCheckConfigFields(props: { · 13 unmodified lines | |||
| 158 | {selected?.summary ? ( | 163 | {selected?.summary ? ( |
| 159 | <p className="text-xs text-muted-foreground">{selected.summary}</p> | 164 | <p className="text-xs text-muted-foreground">{selected.summary}</p> |
| 160 | ) : null} | 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 | <p className="text-xs text-destructive"> | 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 | </p> | 174 | </p> |
| 165 | ) : null} | 175 | ) : null} |
| 166 | </div> | 176 | </div> |
| 167 | 177 | ||
| 168 | {requiredParams.length > 0 ? ( | 178 | {pinnedParams.length > 0 ? ( |
| 169 | <div className="space-y-3"> | 179 | <div className="space-y-3"> |
| 170 | <div className="space-y-0.5"> | 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 | <p className="text-xs text-muted-foreground"> | 182 | <p className="text-xs text-muted-foreground"> |
| 173 | Pinned into every probe. An identity endpoint often needs a fixed value here (for | 183 | Pinned into every probe. An identity endpoint often needs a fixed value here (for |
| 174 | example <span className="font-mono">resourceName</span> ={" "} | 184 | example <span className="font-mono">resourceName</span> ={" "} |
| 175 | <span className="font-mono">people/me</span>). | 185 | <span className="font-mono">people/me</span>). |
| 176 | </p> | 186 | </p> |
| 177 | </div> | 187 | </div> |
| 178 | {requiredParams.map((param) => ( | 188 | {pinnedParams.map((param) => ( |
| 179 | <div key={param.name} className="space-y-1.5"> | 189 | <div key={param.name} className="space-y-1.5"> |
| 180 | <Label htmlFor={`${idPrefix}-arg-${param.name}`}> | 190 | <Label htmlFor={`${idPrefix}-arg-${param.name}`}> |
| 181 | {param.name} | 191 | {param.name} |
| 182 | <span className="ml-1.5 text-xs font-normal text-muted-foreground"> | 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 | </span> | 195 | </span> |
| 185 | </Label> | 196 | </Label> |
| 186 | <Input | 197 | {param.location === "body" ? ( |
| 187 | id={`${idPrefix}-arg-${param.name}`} | 198 | <> |
| 188 | value={args[param.name] ?? ""} | 199 | <Textarea |
| 189 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => | 200 | id={`${idPrefix}-arg-${param.name}`} |
| 190 | props.onArgChange(param.name, e.target.value) | 201 | value={args[param.name] ?? ""} |
| 191 | } | 202 | onChange={(e) => props.onArgChange(param.name, e.target.value)} |
| 192 | placeholder={param.description ?? `Value for ${param.name}`} | 203 | placeholder='{"pageSize": 1}' |
| 193 | disabled={disabled} | 204 | aria-label="Request body (JSON)" |
| 194 | /> | 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 | {param.description ? ( | 224 | {param.description ? ( |
| 196 | <p className="text-xs text-muted-foreground">{param.description}</p> | 225 | <p className="text-xs text-muted-foreground">{param.description}</p> |
| 197 | ) : null} | 226 | ) : null} |
| @@ -253,12 +282,11 @@ function HealthCheckLivePreviewBlock(props: { · 55 unmodified lines | |||
| 253 | const slug = (template || preview.templates[0]?.template) as AuthTemplateSlug | undefined; | 282 | const slug = (template || preview.templates[0]?.template) as AuthTemplateSlug | undefined; |
| 254 | if (slug === undefined) return; | 283 | if (slug === undefined) return; |
| 255 | const identity = identityField.trim(); | 284 | const identity = identityField.trim(); |
| 256 | const argEntries = Object.entries(args) | 285 | const parsedArgs = parseHealthCheckArgs(args); |
| 257 | .map(([key, v]) => [key, v.trim()] as const) | 286 | if (!parsedArgs.ok) return; |
| 258 | .filter(([, v]) => v.length > 0); | ||
| 259 | const spec: HealthCheckSpec = { | 287 | const spec: HealthCheckSpec = { |
| 260 | operation, | 288 | operation, |
| 261 | ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), | 289 | ...(Object.keys(parsedArgs.args).length > 0 ? { args: parsedArgs.args } : {}), |
| 262 | ...(identity.length > 0 ? { identityField: identity } : {}), | 290 | ...(identity.length > 0 ? { identityField: identity } : {}), |
| 263 | }; | 291 | }; |
| 264 | setRunning(true); | 292 | setRunning(true); |
| @@ -382,7 +410,7 @@ function HealthCheckEditorSheet(props: { · 117 unmodified lines | |||
| 382 | const [operation, setOperation] = useState(""); | 410 | const [operation, setOperation] = useState(""); |
| 383 | const [identityField, setIdentityField] = useState(""); | 411 | const [identityField, setIdentityField] = useState(""); |
| 384 | // Pinned argument values keyed by parameter name; stored as strings (the form | 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 | const [args, setArgs] = useState<Record<string, string>>({}); | 414 | const [args, setArgs] = useState<Record<string, string>>({}); |
| 387 | const [saving, setSaving] = useState(false); | 415 | const [saving, setSaving] = useState(false); |
| 388 | 416 | ||
| @@ -391,16 +419,7 @@ function HealthCheckEditorSheet(props: { · 2 unmodified lines | |||
| 391 | if (!open) return; | 419 | if (!open) return; |
| 392 | setOperation(spec?.operation ?? candidates[0]?.operation ?? ""); | 420 | setOperation(spec?.operation ?? candidates[0]?.operation ?? ""); |
| 393 | setIdentityField(spec?.identityField ?? ""); | 421 | setIdentityField(spec?.identityField ?? ""); |
| 394 | setArgs( | 422 | setArgs(formatHealthCheckArgs(spec?.args)); |
| 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 | ); | ||
| 404 | }, [open, spec, candidates]); | 423 | }, [open, spec, candidates]); |
| 405 | 424 | ||
| 406 | const selected = useMemo( | 425 | const selected = useMemo( |
| @@ -413,6 +432,7 @@ function HealthCheckEditorSheet(props: { · 6 unmodified lines | |||
| 413 | [selected], | 432 | [selected], |
| 414 | ); | 433 | ); |
| 415 | const missingRequired = requiredParams.some((p) => (args[p.name] ?? "").trim().length === 0); | 434 | const missingRequired = requiredParams.some((p) => (args[p.name] ?? "").trim().length === 0); |
| 435 | const parsedArgs = parseHealthCheckArgs(args); | ||
| 416 | 436 | ||
| 417 | const onOperationChange = (next: string) => { | 437 | const onOperationChange = (next: string) => { |
| 418 | setOperation(next); | 438 | setOperation(next); |
| @@ -426,15 +446,12 @@ function HealthCheckEditorSheet(props: { · 7 unmodified lines | |||
| 426 | setArgs((prev) => ({ ...prev, [name]: value })); | 446 | setArgs((prev) => ({ ...prev, [name]: value })); |
| 427 | 447 | ||
| 428 | const handleSave = async () => { | 448 | const handleSave = async () => { |
| 429 | if (operation.length === 0) return; | 449 | if (operation.length === 0 || !parsedArgs.ok) return; |
| 430 | setSaving(true); | 450 | setSaving(true); |
| 431 | const identity = identityField.trim(); | 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 | const nextSpec: HealthCheckSpec = { | 452 | const nextSpec: HealthCheckSpec = { |
| 436 | operation, | 453 | operation, |
| 437 | ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), | 454 | ...(Object.keys(parsedArgs.args).length > 0 ? { args: parsedArgs.args } : {}), |
| 438 | ...(identity.length > 0 ? { identityField: identity } : {}), | 455 | ...(identity.length > 0 ? { identityField: identity } : {}), |
| 439 | }; | 456 | }; |
| 440 | const exit = await doSet({ | 457 | const exit = await doSet({ |
| @@ -503,7 +520,9 @@ function HealthCheckEditorSheet(props: { · 62 unmodified lines | |||
| 503 | operation={operation} | 520 | operation={operation} |
| 504 | identityField={identityField} | 521 | identityField={identityField} |
| 505 | args={args} | 522 | args={args} |
| 506 | disabled={saving} | 523 | disabled={ |
| 524 | saving || !parsedArgs.ok || missingRequired || isUnsupportedHealthCheck(selected) | ||
| 525 | } | ||
| 507 | /> | 526 | /> |
| 508 | ) : null} | 527 | ) : null} |
| 509 | </div> | 528 | </div> |
| @@ -511,7 +530,13 @@ function HealthCheckEditorSheet(props: { · 1 unmodified line | |||
| 511 | <SheetFooter> | 530 | <SheetFooter> |
| 512 | <Button | 531 | <Button |
| 513 | onClick={() => void handleSave()} | 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 | {saving ? "Saving..." : "Save"} | 541 | {saving ? "Saving..." : "Save"} |
| 517 | </Button> | 542 | </Button> |
| @@ -597,3 +622,4 @@ export function HealthCheckEditor(props: { · 79 unmodified lines | |||
| 597 | // Re-exported so the add-integration screen can compose the same operation + | 622 | // Re-exported so the add-integration screen can compose the same operation + |
| 598 | // identity form without the sheet/live-preview chrome. | 623 | // identity form without the sheet/live-preview chrome. |
| 599 | export { HealthCheckConfigFields }; | 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 | }; | ||