Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/post-health-checks.md
Original file line number Diff line number Diff line change
@@ -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.
238 changes: 238 additions & 0 deletions e2e/scenarios/health-check-rpc-ui.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
);
}),
),
);
20 changes: 13 additions & 7 deletions e2e/scenarios/health-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
},
},
},
});
Expand Down Expand Up @@ -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({
Expand All @@ -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* () {
Expand Down
19 changes: 12 additions & 7 deletions packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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 ----

Expand Down Expand Up @@ -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
Expand Down
25 changes: 9 additions & 16 deletions packages/plugins/openapi/src/sdk/backing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 } : {}),
Expand Down
Loading
Loading