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
6Support 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 @@
1import { randomBytes } from "node:crypto";
2import { expect } from "@effect/vitest";
3import { Effect } from "effect";
4import { composePluginApi } from "@executor-js/api/server";
5import { connectEmulator } from "@executor-js/emulate";
6import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
7import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
8import { variable } from "@executor-js/sdk/http-auth";
9
10import { createEmulatorInstance } from "../src/emulator-instance";
11import { scenario } from "../src/scenario";
12import { Api, Browser, Target } from "../src/services";
13import { visit } from "../src/surfaces/browser";
14
15const api = composePluginApi([openApiHttpPlugin()] as const);
16const template = AuthTemplateSlug.make("apiKey");
17const name = ConnectionName.make("test");
18const authenticationTemplate = [
19 {
20 slug: template,
21 type: "apiKey" as const,
22 headers: { authorization: ["Bearer ", variable("token")] },
23 },
24];
25const 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
47scenario(
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
162scenario(
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
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 (the494 // 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 approval513 // A health check runs unattended and repeatedly with no approval
510 // gate, so the probe REFUSES to execute a mutating operation: the514 // 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* () {
packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx+12−7
@@ -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";
20import { Button } from "@executor-js/react/components/button";20import { Button } from "@executor-js/react/components/button";
21import { HealthCheckConfigFields } from "@executor-js/react/components/health-check-editor";21import {
22 HealthCheckConfigFields,
23 isUnsupportedHealthCheck,
24 parseHealthCheckArgs,
25} from "@executor-js/react/components/health-check-editor";
22import {26import {
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). The507 // 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 we508 // 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 cannot517 // 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
53import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split";53import { parseEntry, structuralSplit, type KeepPathItem, type SpecStructure } from "./split";
54import { type OpenapiStore, type StoredOperation } from "./store";54import { type OpenapiStore, type StoredOperation } from "./store";
55import { OperationBinding } from "./types";55import { OperationBinding } from "./types";
56import { getHealthCheckParameters } from "./health-check-operation";
56 57
57const STRINGIFIED_BODY_CAP = 1024;58const STRINGIFIED_BODY_CAP = 1024;
58const UpstreamMessageBody = Schema.Struct({ message: Schema.String });59const 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 and946 // HTTP RPC reads can use POST; the editor warns users before enabling them.
946 // repeatedly, so a mutating operation must never execute through it. The947 if (
947 // normal tool path gates these behind approval, and this path has no948 REQUIRE_APPROVAL.has(binding.method.toLowerCase()) &&
948 // approval step. The candidate list labels these "(writes)"; refusing here949 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 } : {}),
packages/plugins/openapi/src/sdk/extract.ts+2−0
@@ -976,6 +976,7 @@ export interface StreamedPreviewParameter { · 975 unmodified lines
976 * add screen's operation list and health-check candidate ranking need, and976 * 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. */
978export interface StreamedPreviewOperation {978export 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 candidates981 /** 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,
packages/plugins/openapi/src/sdk/health-check-operation.test.ts+63−0
@@ -0,0 +1,63 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { dump } from "js-yaml";
4
5import { previewSpecText, previewSpecTextStreaming } from "./preview";
6
7const 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
20describe("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 @@
1import { Option } from "effect";
2import type { HealthCheckCandidateParameter } from "@executor-js/sdk/core";
3
4import type { OperationBinding } from "./types";
5
6export 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
25 type KeepPathItem,25 type KeepPathItem,
26} from "./split";26} from "./split";
27import { HttpMethod, ServerInfo, type ExtractedOperation, type ExtractionResult } from "./types";27import { HttpMethod, ServerInfo, type ExtractedOperation, type ExtractionResult } from "./types";
28import { getHealthCheckParameters } from "./health-check-operation";
28 29
29// Mutating HTTP methods: mirrors `REQUIRE_APPROVAL` in `./invoke` but kept30// Keep potentially mutating methods ranked below ordinary reads. This mirrors
30// inline so this browser-safe preview module never pulls in the HTTP execution31// 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.
32const DESTRUCTIVE_METHODS = new Set(["post", "put", "patch", "delete"]);32const DESTRUCTIVE_METHODS = new Set(["post", "put", "patch", "delete"]);
33 33
34// Cap on health-check candidate METADATA carried in the preview, so the add34// 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
591const streamedCandidate = (op: StreamedPreviewOperation): HealthCheckCandidate => {584const 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
packages/react/src/components/accounts-section.tsx+2−2
@@ -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
packages/react/src/components/health-check-editor.tsx+68−42
@@ -22,9 +22,11 @@ import { · 21 unmodified lines
22import { healthCheckWriteKeys } from "../api/reactivity-keys";22import { healthCheckWriteKeys } from "../api/reactivity-keys";
23import { messageFromExit } from "../api/error-reporting";23import { messageFromExit } from "../api/error-reporting";
24import { HEALTH_STATUS_LABEL, HEALTH_TEXT_CLASS } from "../lib/health-display";24import { HEALTH_STATUS_LABEL, HEALTH_TEXT_CLASS } from "../lib/health-display";
25import { formatHealthCheckArgs, parseHealthCheckArgs } from "../lib/health-check-args";
25import { Button } from "./button";26import { Button } from "./button";
26import { FreeformCombobox, type FreeformComboboxOption } from "./combobox";27import { FreeformCombobox, type FreeformComboboxOption } from "./combobox";
27import { Input } from "./input";28import { Input } from "./input";
29import { Textarea } from "./textarea";
28import { Label } from "./label";30import { Label } from "./label";
29import { NativeSelect, NativeSelectOption } from "./native-select";31import { NativeSelect, NativeSelectOption } from "./native-select";
30import {32import {
@@ -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 a75export 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. */
75const candidateLabel = (candidate: HealthCheckCandidate): string => {79const 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 its85/** 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 (for183 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 <Input197 {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 form412 // 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 <Button531 <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.
599export { HealthCheckConfigFields };624export { HealthCheckConfigFields };
625export { parseHealthCheckArgs } from "../lib/health-check-args";
packages/react/src/lib/health-check-args.test.ts+31−0
@@ -0,0 +1,31 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { formatHealthCheckArgs, parseHealthCheckArgs } from "./health-check-args";
4
5describe("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 @@
1import { Option, Schema } from "effect";
2
3const decodeBody = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
4
5export 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
15export 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};