fix(httpapi): align request body openapi shape (#24811)
This commit is contained in:
parent
2c2fc3499b
commit
c00058ed7a
2 changed files with 97 additions and 3 deletions
|
|
@ -26,14 +26,32 @@ type OpenApiParameter = {
|
||||||
|
|
||||||
type OpenApiOperation = {
|
type OpenApiOperation = {
|
||||||
parameters?: OpenApiParameter[]
|
parameters?: OpenApiParameter[]
|
||||||
|
requestBody?: {
|
||||||
|
required?: boolean
|
||||||
|
content?: Record<string, { schema?: OpenApiSchema }>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
|
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
|
||||||
|
|
||||||
type OpenApiSpec = {
|
type OpenApiSpec = {
|
||||||
|
components?: {
|
||||||
|
schemas?: Record<string, OpenApiSchema>
|
||||||
|
}
|
||||||
paths?: Record<string, OpenApiPathItem>
|
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 = [
|
const InstanceQueryParameters = [
|
||||||
{
|
{
|
||||||
name: "directory",
|
name: "directory",
|
||||||
|
|
@ -49,13 +67,28 @@ const InstanceQueryParameters = [
|
||||||
},
|
},
|
||||||
] satisfies OpenApiParameter[]
|
] 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
|
const spec = input as OpenApiSpec
|
||||||
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
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) {
|
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||||
const operation = item[method]
|
const operation = item[method]
|
||||||
if (!operation) continue
|
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 = [
|
operation.parameters = [
|
||||||
...InstanceQueryParameters,
|
...InstanceQueryParameters,
|
||||||
...(operation.parameters ?? []).filter(
|
...(operation.parameters ?? []).filter(
|
||||||
|
|
@ -67,6 +100,29 @@ function documentInstanceQueryParameters(input: Record<string, unknown>) {
|
||||||
return input
|
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")
|
export const PublicApi = HttpApi.make("opencode")
|
||||||
.addHttpApi(ControlApi)
|
.addHttpApi(ControlApi)
|
||||||
.addHttpApi(GlobalApi)
|
.addHttpApi(GlobalApi)
|
||||||
|
|
@ -91,6 +147,6 @@ export const PublicApi = HttpApi.make("opencode")
|
||||||
title: "opencode",
|
title: "opencode",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
description: "opencode api",
|
description: "opencode api",
|
||||||
transform: documentInstanceQueryParameters,
|
transform: matchLegacyOpenApi,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
type Operation = {
|
||||||
parameters?: unknown[]
|
parameters?: unknown[]
|
||||||
|
requestBody?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestBody = {
|
||||||
|
content?: Record<string, { schema?: { $ref?: string; type?: string } }>
|
||||||
|
required?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function parameterKey(param: unknown) {
|
function parameterKey(param: unknown) {
|
||||||
|
|
@ -60,6 +76,17 @@ function parameterKey(param: unknown) {
|
||||||
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
|
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) {
|
function authorization(username: string, password: string) {
|
||||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +127,17 @@ describe("HttpApi server", () => {
|
||||||
).toEqual([])
|
).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 () => {
|
test("allows requests when auth is disabled", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue