fix(httpapi): finish sdk openapi parity (#24827)
This commit is contained in:
@@ -21,11 +21,12 @@ type OpenApiParameter = {
|
|||||||
name: string
|
name: string
|
||||||
in: string
|
in: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
schema?: unknown
|
schema?: OpenApiSchema
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenApiOperation = {
|
type OpenApiOperation = {
|
||||||
parameters?: OpenApiParameter[]
|
parameters?: OpenApiParameter[]
|
||||||
|
responses?: Record<string, unknown>
|
||||||
requestBody?: {
|
requestBody?: {
|
||||||
required?: boolean
|
required?: boolean
|
||||||
content?: Record<string, { schema?: OpenApiSchema }>
|
content?: Record<string, { schema?: OpenApiSchema }>
|
||||||
@@ -46,8 +47,12 @@ type OpenApiSchema = {
|
|||||||
additionalProperties?: OpenApiSchema | boolean
|
additionalProperties?: OpenApiSchema | boolean
|
||||||
allOf?: OpenApiSchema[]
|
allOf?: OpenApiSchema[]
|
||||||
anyOf?: OpenApiSchema[]
|
anyOf?: OpenApiSchema[]
|
||||||
|
enum?: string[]
|
||||||
items?: OpenApiSchema
|
items?: OpenApiSchema
|
||||||
|
maximum?: number
|
||||||
|
minimum?: number
|
||||||
oneOf?: OpenApiSchema[]
|
oneOf?: OpenApiSchema[]
|
||||||
|
prefixItems?: OpenApiSchema[]
|
||||||
properties?: Record<string, OpenApiSchema>
|
properties?: Record<string, OpenApiSchema>
|
||||||
type?: string
|
type?: string
|
||||||
}
|
}
|
||||||
@@ -68,6 +73,13 @@ const InstanceQueryParameters = [
|
|||||||
] satisfies OpenApiParameter[]
|
] satisfies OpenApiParameter[]
|
||||||
|
|
||||||
const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"])
|
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<string, OpenApiSchema>
|
||||||
|
|
||||||
function matchLegacyOpenApi(input: Record<string, unknown>) {
|
function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||||
const spec = input as OpenApiSpec
|
const spec = input as OpenApiSpec
|
||||||
@@ -87,6 +99,45 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
if (media.schema) media.schema = normalizeRequestSchema(media.schema)
|
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
|
if (!isInstanceRoute) continue
|
||||||
operation.parameters = [
|
operation.parameters = [
|
||||||
@@ -95,22 +146,27 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
|||||||
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
|
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema {
|
function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema {
|
||||||
const options = schema.anyOf ?? schema.oneOf
|
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
|
||||||
if (options) {
|
if (options) {
|
||||||
const withoutNull = options.filter((item) => item.type !== "null")
|
const withoutNull = options.filter((item) => item.type !== "null")
|
||||||
const finite = withoutNull.find((item) => item.type === "number")
|
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 (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0])
|
||||||
if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema)
|
if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema)
|
||||||
if (schema.oneOf) schema.oneOf = 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.items) schema.items = normalizeRequestSchema(schema.items)
|
||||||
if (schema.properties) {
|
if (schema.properties) {
|
||||||
for (const [key, value] of Object.entries(schema.properties)) {
|
for (const [key, value] of Object.entries(schema.properties)) {
|
||||||
@@ -123,6 +179,35 @@ function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema {
|
|||||||
return schema
|
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")
|
export const PublicApi = HttpApi.make("opencode")
|
||||||
.addHttpApi(ControlApi)
|
.addHttpApi(ControlApi)
|
||||||
.addHttpApi(GlobalApi)
|
.addHttpApi(GlobalApi)
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ const original = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const methods = ["get", "post", "put", "delete", "patch"] as const
|
const methods = ["get", "post", "put", "delete", "patch"] as const
|
||||||
|
let effectSpec: ReturnType<typeof OpenApi.fromApi> | undefined
|
||||||
|
|
||||||
|
function effectOpenApi() {
|
||||||
|
return (effectSpec ??= OpenApi.fromApi(PublicApi))
|
||||||
|
}
|
||||||
|
|
||||||
function app(input?: { password?: string; username?: string }) {
|
function app(input?: { password?: string; username?: string }) {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||||
@@ -62,6 +67,7 @@ function openApiRequestBodies(spec: { paths: Record<string, Partial<Record<(type
|
|||||||
|
|
||||||
type Operation = {
|
type Operation = {
|
||||||
parameters?: unknown[]
|
parameters?: unknown[]
|
||||||
|
responses?: unknown
|
||||||
requestBody?: unknown
|
requestBody?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +82,19 @@ 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 parameterSchema(input: {
|
||||||
|
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
|
||||||
|
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) {
|
function requestBodyKey(body: unknown) {
|
||||||
if (!body || typeof body !== "object" || !("content" in body)) return ""
|
if (!body || typeof body !== "object" || !("content" in body)) return ""
|
||||||
const requestBody = body as RequestBody
|
const requestBody = body as RequestBody
|
||||||
@@ -87,6 +106,23 @@ function requestBodyKey(body: unknown) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function responseContentTypes(input: {
|
||||||
|
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
|
||||||
|
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<string, unknown>)[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) {
|
function authorization(username: string, password: string) {
|
||||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||||
}
|
}
|
||||||
@@ -110,7 +146,7 @@ afterEach(async () => {
|
|||||||
describe("HttpApi server", () => {
|
describe("HttpApi server", () => {
|
||||||
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
|
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
|
||||||
const honoRoutes = openApiRouteKeys(await Server.openapi())
|
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(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
|
||||||
expect(effectRoutes.filter((route) => !honoRoutes.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 () => {
|
test("matches generated OpenAPI route parameters", async () => {
|
||||||
const hono = openApiParameters(await Server.openapi())
|
const hono = openApiParameters(await Server.openapi())
|
||||||
const effect = openApiParameters(OpenApi.fromApi(PublicApi))
|
const effect = openApiParameters(effectOpenApi())
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
Object.keys(hono)
|
Object.keys(hono)
|
||||||
@@ -129,7 +165,7 @@ describe("HttpApi server", () => {
|
|||||||
|
|
||||||
test("matches generated OpenAPI request body shape", async () => {
|
test("matches generated OpenAPI request body shape", async () => {
|
||||||
const hono = openApiRequestBodies(await Server.openapi())
|
const hono = openApiRequestBodies(await Server.openapi())
|
||||||
const effect = openApiRequestBodies(OpenApi.fromApi(PublicApi))
|
const effect = openApiRequestBodies(effectOpenApi())
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
Object.keys(hono)
|
Object.keys(hono)
|
||||||
@@ -138,6 +174,38 @@ describe("HttpApi server", () => {
|
|||||||
).toEqual([])
|
).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 () => {
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user