summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-28 12:55:37 -0400
committerGitHub <[email protected]>2026-04-28 12:55:37 -0400
commitc00058ed7a423d1b993362fa2d23a072c5967555 (patch)
tree093d56450212eb15b61de81f214b38ab66afa399
parent2c2fc3499b3723a0595e1666b97207043913f2a8 (diff)
downloadopencode-c00058ed7a423d1b993362fa2d23a072c5967555.tar.gz
opencode-c00058ed7a423d1b993362fa2d23a072c5967555.zip
fix(httpapi): align request body openapi shape (#24811)
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/public.ts62
-rw-r--r--packages/opencode/test/server/httpapi-bridge.test.ts38
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 c26d16e91..caf83ca8c 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<string, { schema?: OpenApiSchema }>
+ }
}
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
type OpenApiSpec = {
+ components?: {
+ schemas?: Record<string, OpenApiSchema>
+ }
paths?: Record<string, OpenApiPathItem>
}
+type OpenApiSchema = {
+ $ref?: string
+ additionalProperties?: OpenApiSchema | boolean
+ allOf?: OpenApiSchema[]
+ anyOf?: OpenApiSchema[]
+ items?: OpenApiSchema
+ oneOf?: OpenApiSchema[]
+ properties?: Record<string, OpenApiSchema>
+ type?: string
+}
+
const InstanceQueryParameters = [
{
name: "directory",
@@ -49,13 +67,28 @@ const InstanceQueryParameters = [
},
] satisfies OpenApiParameter[]
-function documentInstanceQueryParameters(input: Record<string, unknown>) {
+const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"])
+
+function matchLegacyOpenApi(input: Record<string, unknown>) {
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<string, unknown>) {
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 d4d14dbc0..8745a4a0d 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<string, Partial<Record<(typeof
)
}
+function openApiRequestBodies(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
+ 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<string, { schema?: { $ref?: string; type?: string } }>
+ 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")