From c00058ed7a423d1b993362fa2d23a072c5967555 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Apr 2026 12:55:37 -0400 Subject: [PATCH] fix(httpapi): align request body openapi shape (#24811) --- .../server/routes/instance/httpapi/public.ts | 62 ++++++++++++++++++- .../test/server/httpapi-bridge.test.ts | 38 ++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index c26d16e91e..caf83ca8cd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -26,14 +26,32 @@ type OpenApiParameter = { type OpenApiOperation = { parameters?: OpenApiParameter[] + requestBody?: { + required?: boolean + content?: Record + } } type OpenApiPathItem = Partial> type OpenApiSpec = { + components?: { + schemas?: Record + } paths?: Record } +type OpenApiSchema = { + $ref?: string + additionalProperties?: OpenApiSchema | boolean + allOf?: OpenApiSchema[] + anyOf?: OpenApiSchema[] + items?: OpenApiSchema + oneOf?: OpenApiSchema[] + properties?: Record + type?: string +} + const InstanceQueryParameters = [ { name: "directory", @@ -49,13 +67,28 @@ const InstanceQueryParameters = [ }, ] satisfies OpenApiParameter[] -function documentInstanceQueryParameters(input: Record) { +const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"]) + +function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec for (const [path, item] of Object.entries(spec.paths ?? {})) { - if (path.startsWith("/global/") || path.startsWith("/auth/")) continue + const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue + if (operation.requestBody) { + delete operation.requestBody.required + for (const media of Object.values(operation.requestBody.content ?? {})) { + const ref = media.schema?.$ref?.replace("#/components/schemas/", "") + if (ref && LegacyBodyRefParameters.has(ref)) continue + if (ref && spec.components?.schemas?.[ref]) { + media.schema = normalizeRequestSchema(structuredClone(spec.components.schemas[ref])) + continue + } + if (media.schema) media.schema = normalizeRequestSchema(media.schema) + } + } + if (!isInstanceRoute) continue operation.parameters = [ ...InstanceQueryParameters, ...(operation.parameters ?? []).filter( @@ -67,6 +100,29 @@ function documentInstanceQueryParameters(input: Record) { return input } +function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema { + const options = schema.anyOf ?? schema.oneOf + if (options) { + const withoutNull = options.filter((item) => item.type !== "null") + const finite = withoutNull.find((item) => item.type === "number") + if (finite && withoutNull.every((item) => item.type === "number" || item.type === "string")) return finite + if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0]) + if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema) + if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema) + } + if (schema.allOf) schema.allOf = schema.allOf.map(normalizeRequestSchema) + if (schema.items) schema.items = normalizeRequestSchema(schema.items) + if (schema.properties) { + for (const [key, value] of Object.entries(schema.properties)) { + schema.properties[key] = normalizeRequestSchema(value) + } + } + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties) + } + return schema +} + export const PublicApi = HttpApi.make("opencode") .addHttpApi(ControlApi) .addHttpApi(GlobalApi) @@ -91,6 +147,6 @@ export const PublicApi = HttpApi.make("opencode") title: "opencode", version: "1.0.0", description: "opencode api", - transform: documentInstanceQueryParameters, + transform: matchLegacyOpenApi, }), ) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index d4d14dbc0b..8745a4a0d1 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -50,8 +50,24 @@ function openApiParameters(spec: { paths: Record>> }) { + return Object.fromEntries( + Object.entries(spec.paths).flatMap(([path, item]) => + methods + .filter((method) => item[method]) + .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(item[method]?.requestBody)]), + ), + ) +} + type Operation = { parameters?: unknown[] + requestBody?: unknown +} + +type RequestBody = { + content?: Record + required?: boolean } function parameterKey(param: unknown) { @@ -60,6 +76,17 @@ function parameterKey(param: unknown) { return `${param.in}:${param.name}:${"required" in param && param.required === true}` } +function requestBodyKey(body: unknown) { + if (!body || typeof body !== "object" || !("content" in body)) return "" + const requestBody = body as RequestBody + return JSON.stringify({ + required: requestBody.required === true, + content: Object.entries(requestBody.content ?? {}) + .map(([type, value]) => [type, value.schema?.$ref ?? value.schema?.type ?? "inline"]) + .sort(), + }) +} + function authorization(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } @@ -100,6 +127,17 @@ describe("HttpApi server", () => { ).toEqual([]) }) + test("matches generated OpenAPI request body shape", async () => { + const hono = openApiRequestBodies(await Server.openapi()) + const effect = openApiRequestBodies(OpenApi.fromApi(PublicApi)) + + expect( + Object.keys(hono) + .filter((route) => hono[route] !== effect[route]) + .map((route) => ({ route, hono: hono[route], effect: effect[route] })), + ).toEqual([]) + }) + test("allows requests when auth is disabled", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(`${tmp.path}/hello.txt`, "hello")