Просмотр исходного кода

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

Kit Langton 3 месяцев назад
Родитель
Сommit
c00058ed7a

+ 59 - 3
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,
     }),
   )

+ 38 - 0
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")