History

Support POST health checks for HTTP RPC APIs (#1952)

* Support POST health checks for HTTP RPC APIs * Preserve existing health-check method behavior ---------

Alp committed 2 months ago

Commit 1f67d831 parent 65d939e13 files · +505 −88Browse files

Unified · Split

13 files
  1. .changeset/post-health-checks.md +6 −0
  2. e2e/scenarios/health-check-rpc-ui.test.ts +238 −0
  3. e2e/scenarios/health-checks.test.ts +13 −7
  4. packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +12 −7
  5. packages/plugins/openapi/src/sdk/backing.ts +9 −16
  6. packages/plugins/openapi/src/sdk/extract.ts +2 −0
  7. packages/plugins/openapi/src/sdk/health-check-operation.test.ts +63 −0
  8. packages/plugins/openapi/src/sdk/health-check-operation.ts +18 −0
  9. packages/plugins/openapi/src/sdk/preview.ts +13 −14
  10. packages/react/src/components/accounts-section.tsx +2 −2
  11. packages/react/src/components/health-check-editor.tsx +68 −42
  12. packages/react/src/lib/health-check-args.test.ts +31 −0
  13. packages/react/src/lib/health-check-args.ts +30 −0
.changeset/post-health-checks.md+6−0
@@ -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.
e2e/scenarios/health-check-rpc-ui.test.ts+238−0
@@ -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+);
e2e/scenarios/health-checks.test.ts+13−7
@@ -70,6 +70,11 @@ const identitySpec = (baseUrl: string): string => · 69 unmodified lines
7070 summary: "Send a message",
7171 responses: { "201": { description: "created" } },
7272 },
73+ delete: {
74+ operationId: "deleteMessages",
75+ summary: "Delete messages",
76+ responses: { "204": { description: "deleted" } },
77+ },
7378 },
7479 },
7580 });
@@ -486,14 +491,13 @@ scenario( · 410 unmodified lines
486491 Effect.gen(function* () {
487492 yield* registerIdentityIntegration(client, slug, server.url);
488493
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.
491495 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");
494498 yield* client.integrations.healthCheckSet({
495499 params: { slug },
496− payload: { spec: { operation: post.operation } },
500+ payload: { spec: { operation: mutation.operation } },
497501 });
498502
499503 yield* client.connections.create({
@@ -508,13 +512,15 @@ scenario( · 8 unmodified lines
508512
509513 // A health check runs unattended and repeatedly with no approval
510514 // 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.
512516 const result = yield* client.connections.checkHealth({
513517 params: { owner: "org", integration: slug, name },
514518 query: {},
515519 });
516520 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+ );
518524 expect(result.httpStatus, "no request reached the upstream").toBeUndefined();
519525 }),
520526 Effect.gen(function* () {
packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx+12−7
@@ -18,7 +18,11 @@ import { · 17 unmodified lines
1818 useIntegrationIdentity,
1919 } from "@executor-js/react/plugins/integration-identity";
2020 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";
2226 import {
2327 AuthMethodListEditor,
2428 useAuthMethodList,
@@ -374,6 +378,7 @@ export default function AddOpenApiIntegration(props: { · 349 unmodified lines
374378 const hcMissingRequired = hcRequiredParams.some(
375379 (p) => (hcArgs[p.name] ?? "").trim().length === 0,
376380 );
381+ const hcParsedArgs = parseHealthCheckArgs(hcArgs);
377382
378383 const onHcOperationChange = (next: string) => {
379384 setHcOperation(next);
@@ -398,7 +403,10 @@ export default function AddOpenApiIntegration(props: { · 18 unmodified lines
398403 parsedSpecOverrides.ok &&
399404 !slugAlreadyExists &&
400405 (!previewHasNoServers || resolvedBaseUrl.length > 0) &&
401− !(hcOperation.length > 0 && hcMissingRequired);
406+ !(
407+ hcOperation.length > 0 &&
408+ (hcMissingRequired || !hcParsedArgs.ok || isUnsupportedHealthCheck(hcSelected))
409+ );
402410
403411 // ---- Handlers ----
404412
@@ -499,14 +507,11 @@ export default function AddOpenApiIntegration(props: { · 94 unmodified lines
499507 // the user (re-submitting the form hits the slug-already-exists guard). The
500508 // check stays editable from the integration's detail page, so on failure we
501509 // proceed to onComplete regardless and let the user fix it there.
502− if (hcOperation.length > 0) {
510+ if (hcOperation.length > 0 && hcParsedArgs.ok) {
503511 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);
507512 const spec: HealthCheckSpec = {
508513 operation: hcOperation,
509− ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}),
514+ ...(Object.keys(hcParsedArgs.args).length > 0 ? { args: hcParsedArgs.args } : {}),
510515 ...(identity.length > 0 ? { identityField: identity } : {}),
511516 };
512517 // Best-effort: the exit is intentionally ignored so a save failure cannot
packages/plugins/openapi/src/sdk/backing.ts+9−16
@@ -53,6 +53,7 @@ import { parse, type ParsedDocument } from "./parse"; · 52 unmodified lines
5353 import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split";
5454 import { type OpenapiStore, type StoredOperation } from "./store";
5555 import { OperationBinding } from "./types";
56+import { getHealthCheckParameters } from "./health-check-operation";
5657
5758 const STRINGIFIED_BODY_CAP = 1024;
5859 const UpstreamMessageBody = Schema.Struct({ message: Schema.String });
@@ -942,16 +943,15 @@ export const checkHealthOpenApi = (input: { · 883 unmodified lines
942943 } satisfies HealthCheckResult;
943944 }
944945
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+ ) {
951951 return {
952952 status: "unknown",
953953 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.`,
955955 } satisfies HealthCheckResult;
956956 }
957957
@@ -1105,19 +1105,12 @@ export const listHealthCheckCandidatesOpenApi = (input: { · 147 unmodified lines
11051105
11061106 const candidates = operations.map((op): HealthCheckCandidate => {
11071107 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);
11161109 const responseFields = responseFieldsByTool.get(op.toolName);
11171110 return {
11181111 operation: op.toolName,
11191112 method,
1120− requiredArgCount: op.binding.parameters.filter((parameter) => parameter.required).length,
1113+ requiredArgCount: parameters.filter((parameter) => parameter.required).length,
11211114 destructive: REQUIRE_APPROVAL.has(method),
11221115 summary: summaries.get(op.toolName) ?? `${method.toUpperCase()} ${op.binding.pathTemplate}`,
11231116 ...(parameters.length > 0 ? { parameters } : {}),
packages/plugins/openapi/src/sdk/extract.ts+2−0
@@ -976,6 +976,7 @@ export interface StreamedPreviewParameter { · 975 unmodified lines
976976 * add screen's operation list and health-check candidate ranking need, and
977977 * nothing that scales with schema size. */
978978 export interface StreamedPreviewOperation {
979+ readonly requestBodyRequired?: boolean;
979980 readonly operationId: string;
980981 /** Tool path planned over the full kept operation set, so preview candidates
981982 * match the names registration will assign. */
@@ -1050,6 +1051,7 @@ export const streamPreviewOperations = ( · 68 unmodified lines
10501051 }),
10511052 );
10521053 metas.push({
1054+ requestBodyRequired: extractRequestBody(operation, r)?.required,
10531055 operationId,
10541056 method,
10551057 pathTemplate: resolvedPathTemplate,
packages/plugins/openapi/src/sdk/health-check-operation.test.ts+63−0
@@ -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+});
packages/plugins/openapi/src/sdk/health-check-operation.ts+18−0
@@ -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+];
packages/plugins/openapi/src/sdk/preview.ts+13−14
@@ -25,10 +25,10 @@ import { · 24 unmodified lines
2525 type KeepPathItem,
2626 } from "./split";
2727 import { HttpMethod, ServerInfo, type ExtractedOperation, type ExtractionResult } from "./types";
28+import { getHealthCheckParameters } from "./health-check-operation";
2829
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.
3232 const DESTRUCTIVE_METHODS = new Set(["post", "put", "patch", "delete"]);
3333
3434 // Cap on health-check candidate METADATA carried in the preview, so the add
@@ -487,18 +487,11 @@ const buildPreviewHealthCheckCandidates = ( · 452 unmodified lines
487487 .map((def): HealthCheckCandidate => {
488488 const op = def.operation;
489489 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);
498491 return {
499492 operation: def.toolPath,
500493 method,
501− requiredArgCount: op.parameters.filter((parameter) => parameter.required).length,
494+ requiredArgCount: parameters.filter((parameter) => parameter.required).length,
502495 destructive: DESTRUCTIVE_METHODS.has(method),
503496 summary:
504497 Option.getOrUndefined(op.summary) ??
@@ -590,13 +583,19 @@ export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (s · 85 unmodified lines
590583
591584 const streamedCandidate = (op: StreamedPreviewOperation): HealthCheckCandidate => {
592585 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+ ];
593592 return {
594593 operation: op.toolPath,
595594 method,
596− requiredArgCount: op.parameters.filter((parameter) => parameter.required).length,
595+ requiredArgCount: parameters.filter((parameter) => parameter.required).length,
597596 destructive: DESTRUCTIVE_METHODS.has(method),
598597 summary: op.summary ?? op.description ?? `${method.toUpperCase()} ${op.pathTemplate}`,
599− ...(op.parameters.length > 0 ? { parameters: op.parameters } : {}),
598+ ...(parameters.length > 0 ? { parameters } : {}),
600599 };
601600 };
602601
packages/react/src/components/accounts-section.tsx+2−2
@@ -175,7 +175,7 @@ function AccountRow(props: { · 174 unmodified lines
175175 // below, because the remediation is a console visit, not a reconnect.
176176 const misconfigured = status === "misconfigured";
177177 const needsHealthAttention = status === "expired" || status === "degraded";
178− const healthDetail = needsHealthAttention ? probe?.detail : undefined;
178+ const healthDetail = needsHealthAttention || status === "unknown" ? probe?.detail : undefined;
179179 const missingOAuthScopes = connection.missingOAuthScopes ?? [];
180180
181181 const handleCheck = async () => {
@@ -204,7 +204,7 @@ function AccountRow(props: { · 22 unmodified lines
204204 } else if (exit.value.status === "degraded") {
205205 toast.warning(exit.value.detail ?? "Connection check returned an error");
206206 } else {
207− toast.message("No health check is configured for this integration");
207+ toast.message(exit.value.detail ?? "Health check did not run");
208208 }
209209 };
210210
packages/react/src/components/health-check-editor.tsx+68−42
@@ -22,9 +22,11 @@ import { · 21 unmodified lines
2222 import { healthCheckWriteKeys } from "../api/reactivity-keys";
2323 import { messageFromExit } from "../api/error-reporting";
2424 import { HEALTH_STATUS_LABEL, HEALTH_TEXT_CLASS } from "../lib/health-display";
25+import { formatHealthCheckArgs, parseHealthCheckArgs } from "../lib/health-check-args";
2526 import { Button } from "./button";
2627 import { FreeformCombobox, type FreeformComboboxOption } from "./combobox";
2728 import { Input } from "./input";
29+import { Textarea } from "./textarea";
2830 import { Label } from "./label";
2931 import { NativeSelect, NativeSelectOption } from "./native-select";
3032 import {
@@ -70,11 +72,14 @@ export interface HealthCheckLivePreview { · 39 unmodified lines
7072 }>;
7173 }
7274
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. */
7579 const candidateLabel = (candidate: HealthCheckCandidate): string => {
7680 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;
7883 };
7984
8085 /** The summary line for the configured spec: the operation, prefixed with its
@@ -137,8 +142,8 @@ function HealthCheckConfigFields(props: { · 56 unmodified lines
137142 [selected],
138143 );
139144
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"),
142147 [selected],
143148 );
144149
@@ -158,40 +163,64 @@ function HealthCheckConfigFields(props: { · 13 unmodified lines
158163 {selected?.summary ? (
159164 <p className="text-xs text-muted-foreground">{selected.summary}</p>
160165 ) : 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) ? (
162172 <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.
164174 </p>
165175 ) : null}
166176 </div>
167177
168− {requiredParams.length > 0 ? (
178+ {pinnedParams.length > 0 ? (
169179 <div className="space-y-3">
170180 <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>
172182 <p className="text-xs text-muted-foreground">
173183 Pinned into every probe. An identity endpoint often needs a fixed value here (for
174184 example <span className="font-mono">resourceName</span> ={" "}
175185 <span className="font-mono">people/me</span>).
176186 </p>
177187 </div>
178− {requiredParams.map((param) => (
188+ {pinnedParams.map((param) => (
179189 <div key={param.name} className="space-y-1.5">
180190 <Label htmlFor={`${idPrefix}-arg-${param.name}`}>
181191 {param.name}
182192 <span className="ml-1.5 text-xs font-normal text-muted-foreground">
183− ({param.location})
193+ ({param.location}
194+ {param.required ? ", required" : ", optional"})
184195 </span>
185196 </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+ )}
195224 {param.description ? (
196225 <p className="text-xs text-muted-foreground">{param.description}</p>
197226 ) : null}
@@ -253,12 +282,11 @@ function HealthCheckLivePreviewBlock(props: { · 55 unmodified lines
253282 const slug = (template || preview.templates[0]?.template) as AuthTemplateSlug | undefined;
254283 if (slug === undefined) return;
255284 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;
259287 const spec: HealthCheckSpec = {
260288 operation,
261− ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}),
289+ ...(Object.keys(parsedArgs.args).length > 0 ? { args: parsedArgs.args } : {}),
262290 ...(identity.length > 0 ? { identityField: identity } : {}),
263291 };
264292 setRunning(true);
@@ -382,7 +410,7 @@ function HealthCheckEditorSheet(props: { · 117 unmodified lines
382410 const [operation, setOperation] = useState("");
383411 const [identityField, setIdentityField] = useState("");
384412 // 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.
386414 const [args, setArgs] = useState<Record<string, string>>({});
387415 const [saving, setSaving] = useState(false);
388416
@@ -391,16 +419,7 @@ function HealthCheckEditorSheet(props: { · 2 unmodified lines
391419 if (!open) return;
392420 setOperation(spec?.operation ?? candidates[0]?.operation ?? "");
393421 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));
404423 }, [open, spec, candidates]);
405424
406425 const selected = useMemo(
@@ -413,6 +432,7 @@ function HealthCheckEditorSheet(props: { · 6 unmodified lines
413432 [selected],
414433 );
415434 const missingRequired = requiredParams.some((p) => (args[p.name] ?? "").trim().length === 0);
435+ const parsedArgs = parseHealthCheckArgs(args);
416436
417437 const onOperationChange = (next: string) => {
418438 setOperation(next);
@@ -426,15 +446,12 @@ function HealthCheckEditorSheet(props: { · 7 unmodified lines
426446 setArgs((prev) => ({ ...prev, [name]: value }));
427447
428448 const handleSave = async () => {
429− if (operation.length === 0) return;
449+ if (operation.length === 0 || !parsedArgs.ok) return;
430450 setSaving(true);
431451 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);
435452 const nextSpec: HealthCheckSpec = {
436453 operation,
437− ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}),
454+ ...(Object.keys(parsedArgs.args).length > 0 ? { args: parsedArgs.args } : {}),
438455 ...(identity.length > 0 ? { identityField: identity } : {}),
439456 };
440457 const exit = await doSet({
@@ -503,7 +520,9 @@ function HealthCheckEditorSheet(props: { · 62 unmodified lines
503520 operation={operation}
504521 identityField={identityField}
505522 args={args}
506− disabled={saving}
523+ disabled={
524+ saving || !parsedArgs.ok || missingRequired || isUnsupportedHealthCheck(selected)
525+ }
507526 />
508527 ) : null}
509528 </div>
@@ -511,7 +530,13 @@ function HealthCheckEditorSheet(props: { · 1 unmodified line
511530 <SheetFooter>
512531 <Button
513532 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+ }
515540 >
516541 {saving ? "Saving..." : "Save"}
517542 </Button>
@@ -597,3 +622,4 @@ export function HealthCheckEditor(props: { · 79 unmodified lines
597622 // Re-exported so the add-integration screen can compose the same operation +
598623 // identity form without the sheet/live-preview chrome.
599624 export { HealthCheckConfigFields };
625+export { parseHealthCheckArgs } from "../lib/health-check-args";
packages/react/src/lib/health-check-args.test.ts+31−0
@@ -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+});
packages/react/src/lib/health-check-args.ts+30−0
@@ -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+};