fix(httpapi): align request body openapi shape (#24811)

This commit is contained in:
Kit Langton
2026-04-28 12:55:37 -04:00
committed by GitHub
parent 2c2fc3499b
commit c00058ed7a
2 changed files with 97 additions and 3 deletions

View File

@@ -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,
}),
)

View File

@@ -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")