diff --git a/.changeset/post-health-checks.md b/.changeset/post-health-checks.md new file mode 100644 index 0000000000..21fd2d0281 --- /dev/null +++ b/.changeset/post-health-checks.md @@ -0,0 +1,6 @@ +--- +"@executor-js/plugin-openapi": patch +"@executor-js/react": patch +--- + +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. diff --git a/e2e/scenarios/health-check-rpc-ui.test.ts b/e2e/scenarios/health-check-rpc-ui.test.ts new file mode 100644 index 0000000000..b6bf13df25 --- /dev/null +++ b/e2e/scenarios/health-check-rpc-ui.test.ts @@ -0,0 +1,238 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { connectEmulator } from "@executor-js/emulate"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; +import { variable } from "@executor-js/sdk/http-auth"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +const template = AuthTemplateSlug.make("apiKey"); +const name = ConnectionName.make("test"); +const authenticationTemplate = [ + { + slug: template, + type: "apiKey" as const, + headers: { authorization: ["Bearer ", variable("token")] }, + }, +]; +const spec = (baseUrl: string) => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "RPC account health", version: "1" }, + servers: [{ url: baseUrl }], + paths: { + "/api/auth.test": { + post: { + operationId: "getAccount", + requestBody: { + required: true, + content: { "application/json": { schema: { type: "object" } } }, + }, + responses: { "200": { description: "OK" } }, + }, + }, + "/account": { + delete: { operationId: "deleteAccount", responses: { "204": { description: "Deleted" } } }, + }, + }, + }); + +scenario( + "Health checks (UI) · configure and run a POST probe with a warning", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`hc-rpc-${randomBytes(4).toString("hex")}`); + const baseUrl = yield* createEmulatorInstance("slack", "rpc-health"); + const emulator = yield* Effect.promise(() => connectEmulator({ baseUrl })); + const credential = yield* Effect.promise(() => + emulator.credentials.mint({ type: "bearer-token" }), + ); + const token = credential.token; + if (!token) return yield* Effect.die("Emulator did not mint a bearer token"); + const body = { include: { identity: true }, fields: ["user", "team"] }; + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + slug, + baseUrl, + spec: { kind: "blob", value: spec(baseUrl) }, + authenticationTemplate, + }, + }); + yield* browser.session(identity, async ({ page, step }) => { + await step("Choose a POST health check and read its warning", async () => { + await visit(page, `/integrations/${slug}`); + await page.getByRole("button", { name: "Set up", exact: true }).click(); + await page + .getByRole("combobox", { name: "Operation", exact: true }) + .fill("getAccount"); + await page.getByRole("option", { name: /POST.*getAccount/ }).click(); + await page + .getByRole("alert") + .filter({ hasText: "POST requests can change data." }) + .waitFor(); + }); + await step("Reject malformed JSON before running or saving", async () => { + await page.getByRole("textbox", { name: "Request body (JSON)" }).fill('{"include":'); + expect( + await page.getByRole("button", { name: "Save", exact: true }).isEnabled(), + ).toBe(false); + await page.getByText("Enter a valid JSON request body.").waitFor(); + }); + await step("Preview the POST read with a JSON request body", async () => { + await page + .getByRole("textbox", { name: "Request body (JSON)" }) + .fill(JSON.stringify(body)); + await page.getByLabel("Test credential", { exact: true }).fill(token); + await page.getByRole("button", { name: "Preview", exact: true }).click(); + await page.getByText("Healthy", { exact: true }).waitFor(); + await page.getByText("Healthy", { exact: true }).scrollIntoViewIfNeeded(); + }); + await step("Save the health check and reopen its JSON body", async () => { + await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.locator("#health-check-operation").waitFor({ state: "hidden" }); + await page.reload(); + const section = page.locator("section").filter({ + has: page.getByRole("heading", { name: "Health check", exact: true }), + }); + await section.getByRole("button", { name: "Edit", exact: true }).click(); + expect( + JSON.parse( + await page.getByRole("textbox", { name: "Request body (JSON)" }).inputValue(), + ), + ).toEqual(body); + await page + .getByRole("alert") + .filter({ hasText: "POST requests can change data." }) + .waitFor(); + }); + }); + const saved = yield* client.integrations.healthCheckGet({ params: { slug } }); + expect(saved?.args).toEqual({ body }); + yield* client.connections.create({ + payload: { owner: "org", integration: slug, name, template, value: token }, + }); + const result = yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name }, + query: {}, + }); + expect(result.status).toBe("healthy"); + expect(result.httpStatus).toBe(200); + const requests = yield* Effect.promise(() => emulator.ledger.list()); + const probes = requests.filter((request) => request.path === "/api/auth.test"); + expect( + probes.length, + "preview and saved-connection check both reach the upstream", + ).toBeGreaterThanOrEqual(2); + for (const probe of probes) { + expect(probe.method).toBe("POST"); + expect(probe.request.body).toEqual(body); + expect(probe.response.status).toBe(200); + expect(probe.response.body).toMatchObject({ ok: true }); + expect(probe.sideEffects).toEqual([]); + } + }), + Effect.gen(function* () { + yield* client.connections + .remove({ params: { owner: "org", integration: slug, name } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + yield* Effect.promise(() => emulator.reset()); + }), + ); + }), + ), +); + +scenario( + "Health checks (UI) · explain why an unsupported method cannot run", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`hc-refused-${randomBytes(4).toString("hex")}`); + const baseUrl = "https://example.invalid"; + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + slug, + baseUrl, + spec: { kind: "blob", value: spec(baseUrl) }, + authenticationTemplate, + }, + }); + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); + const mutation = candidates.find((candidate) => candidate.method === "delete"); + if (!mutation) return yield* Effect.die("Expected an unsupported DELETE operation"); + yield* client.integrations.healthCheckSet({ + params: { slug }, + payload: { spec: { operation: mutation.operation } }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + integration: slug, + name, + template, + value: "test-only-credential", + }, + }); + const result = yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name }, + query: {}, + }); + expect(result.status).toBe("unknown"); + expect(result.httpStatus).toBeUndefined(); + expect(result.detail).toContain("not supported for health checks"); + yield* browser.session(identity, async ({ page, step }) => { + await step("See why the configured health check could not run", async () => { + await visit(page, `/integrations/${slug}`); + await page.getByText(result.detail!, { exact: true }).waitFor(); + expect( + await page.getByText("No health check configured.", { exact: true }).count(), + ).toBe(0); + }); + await step("Edit the unsupported operation to see how to fix it", async () => { + const section = page.locator("section").filter({ + has: page.getByRole("heading", { name: "Health check", exact: true }), + }); + await section.getByRole("button", { name: "Edit", exact: true }).click(); + await page + .getByText("This method is not supported for health checks.", { exact: false }) + .waitFor(); + expect( + await page.getByRole("button", { name: "Save", exact: true }).isEnabled(), + ).toBe(false); + }); + }); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ params: { owner: "org", integration: slug, name } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/e2e/scenarios/health-checks.test.ts b/e2e/scenarios/health-checks.test.ts index cb8adf53c0..c24a763ad2 100644 --- a/e2e/scenarios/health-checks.test.ts +++ b/e2e/scenarios/health-checks.test.ts @@ -70,6 +70,11 @@ const identitySpec = (baseUrl: string): string => summary: "Send a message", responses: { "201": { description: "created" } }, }, + delete: { + operationId: "deleteMessages", + summary: "Delete messages", + responses: { "204": { description: "deleted" } }, + }, }, }, }); @@ -486,14 +491,13 @@ scenario( Effect.gen(function* () { yield* registerIdentityIntegration(client, slug, server.url); - // Deliberately declare the DESTRUCTIVE POST as the health check (the - // editor warns but allows saving; the runtime is the enforcement). + // API configuration must not bypass the runtime's method restriction. const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); - const post = candidates.find((candidate) => candidate.method === "post"); - if (!post) return yield* Effect.die("identity spec exposed no POST candidate"); + const mutation = candidates.find((candidate) => candidate.method === "delete"); + if (!mutation) return yield* Effect.die("identity spec exposed no DELETE candidate"); yield* client.integrations.healthCheckSet({ params: { slug }, - payload: { spec: { operation: post.operation } }, + payload: { spec: { operation: mutation.operation } }, }); yield* client.connections.create({ @@ -508,13 +512,15 @@ scenario( // A health check runs unattended and repeatedly with no approval // gate, so the probe REFUSES to execute a mutating operation: the - // result is unknown-with-reason and the upstream never sees a POST. + // result is unknown-with-reason and the upstream never sees a DELETE. const result = yield* client.connections.checkHealth({ params: { owner: "org", integration: slug, name }, query: {}, }); expect(result.status, "a mutating probe refuses to run").toBe("unknown"); - expect(result.detail ?? "", "the refusal names the problem").toContain("mutating"); + expect(result.detail ?? "", "the refusal names the problem").toContain( + "not supported for health checks", + ); expect(result.httpStatus, "no request reached the upstream").toBeUndefined(); }), Effect.gen(function* () { diff --git a/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx b/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx index bb8e303a32..9fcb2de7fb 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx @@ -18,7 +18,11 @@ import { useIntegrationIdentity, } from "@executor-js/react/plugins/integration-identity"; import { Button } from "@executor-js/react/components/button"; -import { HealthCheckConfigFields } from "@executor-js/react/components/health-check-editor"; +import { + HealthCheckConfigFields, + isUnsupportedHealthCheck, + parseHealthCheckArgs, +} from "@executor-js/react/components/health-check-editor"; import { AuthMethodListEditor, useAuthMethodList, @@ -374,6 +378,7 @@ export default function AddOpenApiIntegration(props: { const hcMissingRequired = hcRequiredParams.some( (p) => (hcArgs[p.name] ?? "").trim().length === 0, ); + const hcParsedArgs = parseHealthCheckArgs(hcArgs); const onHcOperationChange = (next: string) => { setHcOperation(next); @@ -398,7 +403,10 @@ export default function AddOpenApiIntegration(props: { parsedSpecOverrides.ok && !slugAlreadyExists && (!previewHasNoServers || resolvedBaseUrl.length > 0) && - !(hcOperation.length > 0 && hcMissingRequired); + !( + hcOperation.length > 0 && + (hcMissingRequired || !hcParsedArgs.ok || isUnsupportedHealthCheck(hcSelected)) + ); // ---- Handlers ---- @@ -499,14 +507,11 @@ export default function AddOpenApiIntegration(props: { // the user (re-submitting the form hits the slug-already-exists guard). The // check stays editable from the integration's detail page, so on failure we // proceed to onComplete regardless and let the user fix it there. - if (hcOperation.length > 0) { + if (hcOperation.length > 0 && hcParsedArgs.ok) { const identity = hcIdentityField.trim(); - const argEntries = Object.entries(hcArgs) - .map(([key, value]) => [key, value.trim()] as const) - .filter(([, value]) => value.length > 0); const spec: HealthCheckSpec = { operation: hcOperation, - ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), + ...(Object.keys(hcParsedArgs.args).length > 0 ? { args: hcParsedArgs.args } : {}), ...(identity.length > 0 ? { identityField: identity } : {}), }; // Best-effort: the exit is intentionally ignored so a save failure cannot diff --git a/packages/plugins/openapi/src/sdk/backing.ts b/packages/plugins/openapi/src/sdk/backing.ts index b7a54d4f43..a5132b7701 100644 --- a/packages/plugins/openapi/src/sdk/backing.ts +++ b/packages/plugins/openapi/src/sdk/backing.ts @@ -53,6 +53,7 @@ import { parse, type ParsedDocument } from "./parse"; import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split"; import { type OpenapiStore, type StoredOperation } from "./store"; import { OperationBinding } from "./types"; +import { getHealthCheckParameters } from "./health-check-operation"; const STRINGIFIED_BODY_CAP = 1024; const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); @@ -905,16 +906,15 @@ export const checkHealthOpenApi = (input: { } satisfies HealthCheckResult; } - // HARD block, not just a ranking hint: a health check runs unattended and - // repeatedly, so a mutating operation must never execute through it. The - // normal tool path gates these behind approval, and this path has no - // approval step. The candidate list labels these "(writes)"; refusing here - // is the enforcement. - if (REQUIRE_APPROVAL.has(binding.method.toLowerCase())) { + // HTTP RPC reads can use POST; the editor warns users before enabling them. + if ( + REQUIRE_APPROVAL.has(binding.method.toLowerCase()) && + binding.method.toLowerCase() !== "post" + ) { return { status: "unknown", checkedAt, - detail: `Health check operation "${spec.operation}" is a ${binding.method.toUpperCase()} (mutating): pick a read-only operation.`, + detail: `Health check operation "${spec.operation}" uses ${binding.method.toUpperCase()} and is not supported for health checks. Pick a read-only operation.`, } satisfies HealthCheckResult; } @@ -1068,19 +1068,12 @@ export const listHealthCheckCandidatesOpenApi = (input: { const candidates = operations.map((op): HealthCheckCandidate => { const method = op.binding.method.toLowerCase(); - const parameters = op.binding.parameters.map((parameter) => ({ - name: parameter.name, - location: parameter.location, - required: parameter.required, - ...(Option.isSome(parameter.description) - ? { description: parameter.description.value } - : {}), - })); + const parameters = getHealthCheckParameters(op.binding); const responseFields = responseFieldsByTool.get(op.toolName); return { operation: op.toolName, method, - requiredArgCount: op.binding.parameters.filter((parameter) => parameter.required).length, + requiredArgCount: parameters.filter((parameter) => parameter.required).length, destructive: REQUIRE_APPROVAL.has(method), summary: summaries.get(op.toolName) ?? `${method.toUpperCase()} ${op.binding.pathTemplate}`, ...(parameters.length > 0 ? { parameters } : {}), diff --git a/packages/plugins/openapi/src/sdk/extract.ts b/packages/plugins/openapi/src/sdk/extract.ts index c242160a18..c11c5e0f99 100644 --- a/packages/plugins/openapi/src/sdk/extract.ts +++ b/packages/plugins/openapi/src/sdk/extract.ts @@ -976,6 +976,7 @@ export interface StreamedPreviewParameter { * add screen's operation list and health-check candidate ranking need, and * nothing that scales with schema size. */ export interface StreamedPreviewOperation { + readonly requestBodyRequired?: boolean; readonly operationId: string; /** Tool path planned over the full kept operation set, so preview candidates * match the names registration will assign. */ @@ -1050,6 +1051,7 @@ export const streamPreviewOperations = ( }), ); metas.push({ + requestBodyRequired: extractRequestBody(operation, r)?.required, operationId, method, pathTemplate: resolvedPathTemplate, diff --git a/packages/plugins/openapi/src/sdk/health-check-operation.test.ts b/packages/plugins/openapi/src/sdk/health-check-operation.test.ts new file mode 100644 index 0000000000..b478da2554 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/health-check-operation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { dump } from "js-yaml"; + +import { previewSpecText, previewSpecTextStreaming } from "./preview"; + +const readOperation = { + operationId: "getAccount", + parameters: [ + { + name: "RPC-Version", + in: "header", + required: true, + schema: { type: "string" }, + }, + ], + responses: { "200": { description: "OK" } }, +}; + +describe("health-check operations", () => { + it.effect("keeps body requirements and POST risk visible in both preview paths", () => + Effect.gen(function* () { + for (const required of [true, false]) { + const specText = dump({ + openapi: "3.0.3", + info: { title: "Account RPC API", version: "1" }, + servers: [{ url: "https://api.example.test" }], + paths: { + "/rpc/getAccount": { + post: { + ...readOperation, + requestBody: { + required, + content: { "application/json": { schema: { type: "object" } } }, + }, + }, + }, + "/me": { + get: { + operationId: "getMe", + responses: { "200": { description: "OK" } }, + }, + }, + }, + }); + const whole = yield* previewSpecText(specText); + const streamed = yield* previewSpecTextStreaming(specText); + expect(streamed.healthCheckCandidates).toEqual(whole.healthCheckCandidates); + expect(whole.healthCheckCandidates[0]?.method).toBe("get"); + expect( + whole.healthCheckCandidates.find((candidate) => candidate.method === "post"), + ).toMatchObject({ + destructive: true, + requiredArgCount: required ? 2 : 1, + parameters: [ + { name: "RPC-Version", location: "header", required: true }, + { name: "body", location: "body", required }, + ], + }); + } + }), + ); +}); diff --git a/packages/plugins/openapi/src/sdk/health-check-operation.ts b/packages/plugins/openapi/src/sdk/health-check-operation.ts new file mode 100644 index 0000000000..f238665858 --- /dev/null +++ b/packages/plugins/openapi/src/sdk/health-check-operation.ts @@ -0,0 +1,18 @@ +import { Option } from "effect"; +import type { HealthCheckCandidateParameter } from "@executor-js/sdk/core"; + +import type { OperationBinding } from "./types"; + +export const getHealthCheckParameters = ( + operation: Pick, +): HealthCheckCandidateParameter[] => [ + ...operation.parameters.map((parameter) => ({ + name: parameter.name, + location: parameter.location, + required: parameter.required, + ...(Option.isSome(parameter.description) ? { description: parameter.description.value } : {}), + })), + ...(Option.isSome(operation.requestBody) + ? [{ name: "body", location: "body", required: operation.requestBody.value.required }] + : []), +]; diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts index 51279af8f8..84506ad475 100644 --- a/packages/plugins/openapi/src/sdk/preview.ts +++ b/packages/plugins/openapi/src/sdk/preview.ts @@ -25,10 +25,10 @@ import { type KeepPathItem, } from "./split"; import { HttpMethod, ServerInfo, type ExtractedOperation, type ExtractionResult } from "./types"; +import { getHealthCheckParameters } from "./health-check-operation"; -// Mutating HTTP methods: mirrors `REQUIRE_APPROVAL` in `./invoke` but kept -// inline so this browser-safe preview module never pulls in the HTTP execution -// path. A health check should be safe to re-run, so these rank last. +// Keep potentially mutating methods ranked below ordinary reads. This mirrors +// REQUIRE_APPROVAL without importing the HTTP execution path into the browser. const DESTRUCTIVE_METHODS = new Set(["post", "put", "patch", "delete"]); // Cap on health-check candidate METADATA carried in the preview, so the add @@ -487,18 +487,11 @@ const buildPreviewHealthCheckCandidates = ( .map((def): HealthCheckCandidate => { const op = def.operation; const method = op.method.toLowerCase(); - const parameters = op.parameters.map((parameter) => ({ - name: parameter.name, - location: parameter.location, - required: parameter.required, - ...(Option.isSome(parameter.description) - ? { description: parameter.description.value } - : {}), - })); + const parameters = getHealthCheckParameters(op); return { operation: def.toolPath, method, - requiredArgCount: op.parameters.filter((parameter) => parameter.required).length, + requiredArgCount: parameters.filter((parameter) => parameter.required).length, destructive: DESTRUCTIVE_METHODS.has(method), summary: Option.getOrUndefined(op.summary) ?? @@ -590,13 +583,19 @@ export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (s const streamedCandidate = (op: StreamedPreviewOperation): HealthCheckCandidate => { const method = op.method.toLowerCase(); + const parameters = [ + ...op.parameters, + ...(op.requestBodyRequired === undefined + ? [] + : [{ name: "body", location: "body", required: op.requestBodyRequired }]), + ]; return { operation: op.toolPath, method, - requiredArgCount: op.parameters.filter((parameter) => parameter.required).length, + requiredArgCount: parameters.filter((parameter) => parameter.required).length, destructive: DESTRUCTIVE_METHODS.has(method), summary: op.summary ?? op.description ?? `${method.toUpperCase()} ${op.pathTemplate}`, - ...(op.parameters.length > 0 ? { parameters: op.parameters } : {}), + ...(parameters.length > 0 ? { parameters } : {}), }; }; diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index d9189a796f..8f116cc452 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -175,7 +175,7 @@ function AccountRow(props: { // below, because the remediation is a console visit, not a reconnect. const misconfigured = status === "misconfigured"; const needsHealthAttention = status === "expired" || status === "degraded"; - const healthDetail = needsHealthAttention ? probe?.detail : undefined; + const healthDetail = needsHealthAttention || status === "unknown" ? probe?.detail : undefined; const missingOAuthScopes = connection.missingOAuthScopes ?? []; const handleCheck = async () => { @@ -204,7 +204,7 @@ function AccountRow(props: { } else if (exit.value.status === "degraded") { toast.warning(exit.value.detail ?? "Connection check returned an error"); } else { - toast.message("No health check is configured for this integration"); + toast.message(exit.value.detail ?? "Health check did not run"); } }; diff --git a/packages/react/src/components/health-check-editor.tsx b/packages/react/src/components/health-check-editor.tsx index ea6076fb6e..f3c41257d5 100644 --- a/packages/react/src/components/health-check-editor.tsx +++ b/packages/react/src/components/health-check-editor.tsx @@ -22,9 +22,11 @@ import { import { healthCheckWriteKeys } from "../api/reactivity-keys"; import { messageFromExit } from "../api/error-reporting"; import { HEALTH_STATUS_LABEL, HEALTH_TEXT_CLASS } from "../lib/health-display"; +import { formatHealthCheckArgs, parseHealthCheckArgs } from "../lib/health-check-args"; import { Button } from "./button"; import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; import { Input } from "./input"; +import { Textarea } from "./textarea"; import { Label } from "./label"; import { NativeSelect, NativeSelectOption } from "./native-select"; import { @@ -70,11 +72,14 @@ export interface HealthCheckLivePreview { }>; } -/** "GET /users/me" style label for a candidate, with a writes marker so a - * mutating operation picked as a health check reads as the hazard it is. */ +export const isUnsupportedHealthCheck = (candidate: HealthCheckCandidate | null): boolean => + candidate?.destructive === true && candidate.method.toLowerCase() !== "post"; + +/** POST may be a read operation in an HTTP RPC API, but needs a warning. */ const candidateLabel = (candidate: HealthCheckCandidate): string => { const head = `${candidate.method.toUpperCase()} ${candidate.operation}`; - return candidate.destructive ? `${head} (writes)` : head; + if (candidate.method.toLowerCase() === "post") return `${head} (may change data)`; + return isUnsupportedHealthCheck(candidate) ? `${head} (unsupported for health checks)` : head; }; /** The summary line for the configured spec: the operation, prefixed with its @@ -137,8 +142,8 @@ function HealthCheckConfigFields(props: { [selected], ); - const requiredParams = useMemo( - () => (selected?.parameters ?? []).filter((p) => p.required), + const pinnedParams = useMemo( + () => (selected?.parameters ?? []).filter((p) => p.required || p.location === "body"), [selected], ); @@ -158,40 +163,64 @@ function HealthCheckConfigFields(props: { {selected?.summary ? (

{selected.summary}

) : null} - {selected?.destructive ? ( + {selected?.method.toLowerCase() === "post" ? ( +

+ POST requests can change data. Health checks run automatically and repeatedly; choose an + operation that only reads data. +

+ ) : isUnsupportedHealthCheck(selected) ? (

- This operation writes data. Prefer a read-only (GET) operation for a health check. + This method is not supported for health checks. Pick a read-only operation.

) : null} - {requiredParams.length > 0 ? ( + {pinnedParams.length > 0 ? (
-

Required arguments

+

Pinned arguments

Pinned into every probe. An identity endpoint often needs a fixed value here (for example resourceName ={" "} people/me).

- {requiredParams.map((param) => ( + {pinnedParams.map((param) => (
- ) => - props.onArgChange(param.name, e.target.value) - } - placeholder={param.description ?? `Value for ${param.name}`} - disabled={disabled} - /> + {param.location === "body" ? ( + <> +