From 2e8d690ab12a207141d88b1269e8d0625708d7ae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Apr 2026 14:24:10 -0400 Subject: [PATCH] fix(httpapi): finish sdk openapi parity (#24827) --- .../server/routes/instance/httpapi/public.ts | 93 ++++++++++++++++++- .../test/server/httpapi-bridge.test.ts | 74 ++++++++++++++- 2 files changed, 160 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index caf83ca8cd..a4e86e9a5f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -21,11 +21,12 @@ type OpenApiParameter = { name: string in: string required?: boolean - schema?: unknown + schema?: OpenApiSchema } type OpenApiOperation = { parameters?: OpenApiParameter[] + responses?: Record requestBody?: { required?: boolean content?: Record @@ -46,8 +47,12 @@ type OpenApiSchema = { additionalProperties?: OpenApiSchema | boolean allOf?: OpenApiSchema[] anyOf?: OpenApiSchema[] + enum?: string[] items?: OpenApiSchema + maximum?: number + minimum?: number oneOf?: OpenApiSchema[] + prefixItems?: OpenApiSchema[] properties?: Record type?: string } @@ -68,6 +73,13 @@ const InstanceQueryParameters = [ ] satisfies OpenApiParameter[] const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"]) +const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"]) +const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) +const QueryBooleanParameters = new Set(["roots", "archived"]) +const QueryParameterSchemas = { + "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, + "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, +} satisfies Record function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec @@ -87,6 +99,45 @@ function matchLegacyOpenApi(input: Record) { } if (media.schema) media.schema = normalizeRequestSchema(media.schema) } + if (path === "/experimental/workspace" && method === "post") { + const properties = operation.requestBody.content?.["application/json"]?.schema?.properties + if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } + if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } + } + if (path === "/tui/publish" && method === "post" && spec.components?.schemas) { + const schema = operation.requestBody.content?.["application/json"]?.schema + const anyOf = schema?.anyOf + if (anyOf?.length === 4) { + spec.components.schemas.EventTuiPromptAppend = anyOf[0] + spec.components.schemas.EventTuiCommandExecute = anyOf[1] + spec.components.schemas.EventTuiToastShow = anyOf[2] + spec.components.schemas.EventTuiSessionSelect = anyOf[3] + operation.requestBody.content!["application/json"]!.schema = { + anyOf: [ + { $ref: "#/components/schemas/EventTuiPromptAppend" }, + { $ref: "#/components/schemas/EventTuiCommandExecute" }, + { $ref: "#/components/schemas/EventTuiToastShow" }, + { $ref: "#/components/schemas/EventTuiSessionSelect" }, + ], + } + } + } + if (path === "/sync/replay" && method === "post" && spec.components?.schemas?.SyncReplayEvent) { + const events = operation.requestBody.content?.["application/json"]?.schema?.properties?.events + if (events?.items?.$ref === "#/components/schemas/SyncReplayEvent") { + events.items = normalizeRequestSchema(structuredClone(spec.components.schemas.SyncReplayEvent)) + } + } + } + if ((path === "/event" || path === "/global/event") && method === "get") { + operation.responses!["200"] = { + description: "Event stream", + content: { + "text/event-stream": { + schema: path === "/event" ? {} : { $ref: "#/components/schemas/GlobalEvent" }, + }, + }, + } } if (!isInstanceRoute) continue operation.parameters = [ @@ -95,22 +146,27 @@ function matchLegacyOpenApi(input: Record) { (param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"), ), ] + for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`) } } return input } function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema { - const options = schema.anyOf ?? schema.oneOf + const options = flattenOptions(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 (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" } 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.allOf) { + if (schema.type) delete schema.allOf + else schema.allOf = schema.allOf.map(normalizeRequestSchema) + } + if (schema.prefixItems && schema.items) delete schema.prefixItems if (schema.items) schema.items = normalizeRequestSchema(schema.items) if (schema.properties) { for (const [key, value] of Object.entries(schema.properties)) { @@ -123,6 +179,35 @@ function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema { return schema } +function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined { + return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item]) +} + +function isFiniteNumberOption(schema: OpenApiSchema) { + if (schema.type === "number") return true + return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true +} + +function normalizeParameter(param: OpenApiParameter, route: string) { + if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return + const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + if (override) { + param.schema = override + return + } + if (QueryNumberParameters.has(param.name)) { + param.schema = { type: "number" } + return + } + if (QueryBooleanParameters.has(param.name)) { + param.schema = { + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + } + return + } + param.schema = normalizeRequestSchema(param.schema) +} + export const PublicApi = HttpApi.make("opencode") .addHttpApi(ControlApi) .addHttpApi(GlobalApi) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 8745a4a0d1..85da13267f 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -18,6 +18,11 @@ const original = { } const methods = ["get", "post", "put", "delete", "patch"] as const +let effectSpec: ReturnType | undefined + +function effectOpenApi() { + return (effectSpec ??= OpenApi.fromApi(PublicApi)) +} function app(input?: { password?: string; username?: string }) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true @@ -62,6 +67,7 @@ function openApiRequestBodies(spec: { paths: Record>> } + path: string + method: (typeof methods)[number] + name: string +}) { + const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find( + (param) => !!param && typeof param === "object" && "name" in param && param.name === input.name, + ) + if (!param || typeof param !== "object" || !("schema" in param)) return + return param.schema +} + function requestBodyKey(body: unknown) { if (!body || typeof body !== "object" || !("content" in body)) return "" const requestBody = body as RequestBody @@ -87,6 +106,23 @@ function requestBodyKey(body: unknown) { }) } +function responseContentTypes(input: { + spec: { paths: Record>> } + path: string + method: (typeof methods)[number] + status: string +}) { + const responses = input.spec.paths[input.path]?.[input.method]?.responses + if (!responses || typeof responses !== "object" || !(input.status in responses)) return [] + const response = (responses as Record)[input.status] + if (!response || typeof response !== "object" || !("content" in response)) return [] + const content = (response as { content?: unknown }).content + if (!content || typeof content !== "object") { + return [] + } + return Object.keys(content).sort() +} + function authorization(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } @@ -110,7 +146,7 @@ afterEach(async () => { describe("HttpApi server", () => { test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { const honoRoutes = openApiRouteKeys(await Server.openapi()) - const effectRoutes = openApiRouteKeys(OpenApi.fromApi(PublicApi)) + const effectRoutes = openApiRouteKeys(effectOpenApi()) expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([]) @@ -118,7 +154,7 @@ describe("HttpApi server", () => { test("matches generated OpenAPI route parameters", async () => { const hono = openApiParameters(await Server.openapi()) - const effect = openApiParameters(OpenApi.fromApi(PublicApi)) + const effect = openApiParameters(effectOpenApi()) expect( Object.keys(hono) @@ -129,7 +165,7 @@ describe("HttpApi server", () => { test("matches generated OpenAPI request body shape", async () => { const hono = openApiRequestBodies(await Server.openapi()) - const effect = openApiRequestBodies(OpenApi.fromApi(PublicApi)) + const effect = openApiRequestBodies(effectOpenApi()) expect( Object.keys(hono) @@ -138,6 +174,38 @@ describe("HttpApi server", () => { ).toEqual([]) }) + test("matches SDK-affecting query parameter schemas", async () => { + const effect = effectOpenApi() + + expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({ + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + }) + expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({ + type: "number", + }) + expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({ + type: "integer", + minimum: 1, + maximum: 200, + }) + expect(parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" })).toEqual({ + type: "integer", + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + }) + }) + + test("documents event routes as server-sent events", () => { + const effect = effectOpenApi() + + expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([ + "text/event-stream", + ]) + expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([ + "text/event-stream", + ]) + }) + test("allows requests when auth is disabled", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(`${tmp.path}/hello.txt`, "hello")