Skip to content

Commit 2e8d690

Browse files
authored
fix(httpapi): finish sdk openapi parity (#24827)
1 parent 1ff8d28 commit 2e8d690

2 files changed

Lines changed: 160 additions & 7 deletions

File tree

packages/opencode/src/server/routes/instance/httpapi/public.ts

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ type OpenApiParameter = {
2121
name: string
2222
in: string
2323
required?: boolean
24-
schema?: unknown
24+
schema?: OpenApiSchema
2525
}
2626

2727
type OpenApiOperation = {
2828
parameters?: OpenApiParameter[]
29+
responses?: Record<string, unknown>
2930
requestBody?: {
3031
required?: boolean
3132
content?: Record<string, { schema?: OpenApiSchema }>
@@ -46,8 +47,12 @@ type OpenApiSchema = {
4647
additionalProperties?: OpenApiSchema | boolean
4748
allOf?: OpenApiSchema[]
4849
anyOf?: OpenApiSchema[]
50+
enum?: string[]
4951
items?: OpenApiSchema
52+
maximum?: number
53+
minimum?: number
5054
oneOf?: OpenApiSchema[]
55+
prefixItems?: OpenApiSchema[]
5156
properties?: Record<string, OpenApiSchema>
5257
type?: string
5358
}
@@ -68,6 +73,13 @@ const InstanceQueryParameters = [
6873
] satisfies OpenApiParameter[]
6974

7075
const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"])
76+
const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"])
77+
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
78+
const QueryBooleanParameters = new Set(["roots", "archived"])
79+
const QueryParameterSchemas = {
80+
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
81+
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
82+
} satisfies Record<string, OpenApiSchema>
7183

7284
function matchLegacyOpenApi(input: Record<string, unknown>) {
7385
const spec = input as OpenApiSpec
@@ -87,6 +99,45 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
8799
}
88100
if (media.schema) media.schema = normalizeRequestSchema(media.schema)
89101
}
102+
if (path === "/experimental/workspace" && method === "post") {
103+
const properties = operation.requestBody.content?.["application/json"]?.schema?.properties
104+
if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
105+
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
106+
}
107+
if (path === "/tui/publish" && method === "post" && spec.components?.schemas) {
108+
const schema = operation.requestBody.content?.["application/json"]?.schema
109+
const anyOf = schema?.anyOf
110+
if (anyOf?.length === 4) {
111+
spec.components.schemas.EventTuiPromptAppend = anyOf[0]
112+
spec.components.schemas.EventTuiCommandExecute = anyOf[1]
113+
spec.components.schemas.EventTuiToastShow = anyOf[2]
114+
spec.components.schemas.EventTuiSessionSelect = anyOf[3]
115+
operation.requestBody.content!["application/json"]!.schema = {
116+
anyOf: [
117+
{ $ref: "#/components/schemas/EventTuiPromptAppend" },
118+
{ $ref: "#/components/schemas/EventTuiCommandExecute" },
119+
{ $ref: "#/components/schemas/EventTuiToastShow" },
120+
{ $ref: "#/components/schemas/EventTuiSessionSelect" },
121+
],
122+
}
123+
}
124+
}
125+
if (path === "/sync/replay" && method === "post" && spec.components?.schemas?.SyncReplayEvent) {
126+
const events = operation.requestBody.content?.["application/json"]?.schema?.properties?.events
127+
if (events?.items?.$ref === "#/components/schemas/SyncReplayEvent") {
128+
events.items = normalizeRequestSchema(structuredClone(spec.components.schemas.SyncReplayEvent))
129+
}
130+
}
131+
}
132+
if ((path === "/event" || path === "/global/event") && method === "get") {
133+
operation.responses!["200"] = {
134+
description: "Event stream",
135+
content: {
136+
"text/event-stream": {
137+
schema: path === "/event" ? {} : { $ref: "#/components/schemas/GlobalEvent" },
138+
},
139+
},
140+
}
90141
}
91142
if (!isInstanceRoute) continue
92143
operation.parameters = [
@@ -95,22 +146,27 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
95146
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
96147
),
97148
]
149+
for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
98150
}
99151
}
100152
return input
101153
}
102154

103155
function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema {
104-
const options = schema.anyOf ?? schema.oneOf
156+
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
105157
if (options) {
106158
const withoutNull = options.filter((item) => item.type !== "null")
107159
const finite = withoutNull.find((item) => item.type === "number")
108-
if (finite && withoutNull.every((item) => item.type === "number" || item.type === "string")) return finite
160+
if (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" }
109161
if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0])
110162
if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema)
111163
if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema)
112164
}
113-
if (schema.allOf) schema.allOf = schema.allOf.map(normalizeRequestSchema)
165+
if (schema.allOf) {
166+
if (schema.type) delete schema.allOf
167+
else schema.allOf = schema.allOf.map(normalizeRequestSchema)
168+
}
169+
if (schema.prefixItems && schema.items) delete schema.prefixItems
114170
if (schema.items) schema.items = normalizeRequestSchema(schema.items)
115171
if (schema.properties) {
116172
for (const [key, value] of Object.entries(schema.properties)) {
@@ -123,6 +179,35 @@ function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema {
123179
return schema
124180
}
125181

182+
function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined {
183+
return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item])
184+
}
185+
186+
function isFiniteNumberOption(schema: OpenApiSchema) {
187+
if (schema.type === "number") return true
188+
return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true
189+
}
190+
191+
function normalizeParameter(param: OpenApiParameter, route: string) {
192+
if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return
193+
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
194+
if (override) {
195+
param.schema = override
196+
return
197+
}
198+
if (QueryNumberParameters.has(param.name)) {
199+
param.schema = { type: "number" }
200+
return
201+
}
202+
if (QueryBooleanParameters.has(param.name)) {
203+
param.schema = {
204+
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
205+
}
206+
return
207+
}
208+
param.schema = normalizeRequestSchema(param.schema)
209+
}
210+
126211
export const PublicApi = HttpApi.make("opencode")
127212
.addHttpApi(ControlApi)
128213
.addHttpApi(GlobalApi)

packages/opencode/test/server/httpapi-bridge.test.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ const original = {
1818
}
1919

2020
const methods = ["get", "post", "put", "delete", "patch"] as const
21+
let effectSpec: ReturnType<typeof OpenApi.fromApi> | undefined
22+
23+
function effectOpenApi() {
24+
return (effectSpec ??= OpenApi.fromApi(PublicApi))
25+
}
2126

2227
function app(input?: { password?: string; username?: string }) {
2328
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
@@ -62,6 +67,7 @@ function openApiRequestBodies(spec: { paths: Record<string, Partial<Record<(type
6267

6368
type Operation = {
6469
parameters?: unknown[]
70+
responses?: unknown
6571
requestBody?: unknown
6672
}
6773

@@ -76,6 +82,19 @@ function parameterKey(param: unknown) {
7682
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
7783
}
7884

85+
function parameterSchema(input: {
86+
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
87+
path: string
88+
method: (typeof methods)[number]
89+
name: string
90+
}) {
91+
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
92+
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
93+
)
94+
if (!param || typeof param !== "object" || !("schema" in param)) return
95+
return param.schema
96+
}
97+
7998
function requestBodyKey(body: unknown) {
8099
if (!body || typeof body !== "object" || !("content" in body)) return ""
81100
const requestBody = body as RequestBody
@@ -87,6 +106,23 @@ function requestBodyKey(body: unknown) {
87106
})
88107
}
89108

109+
function responseContentTypes(input: {
110+
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
111+
path: string
112+
method: (typeof methods)[number]
113+
status: string
114+
}) {
115+
const responses = input.spec.paths[input.path]?.[input.method]?.responses
116+
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
117+
const response = (responses as Record<string, unknown>)[input.status]
118+
if (!response || typeof response !== "object" || !("content" in response)) return []
119+
const content = (response as { content?: unknown }).content
120+
if (!content || typeof content !== "object") {
121+
return []
122+
}
123+
return Object.keys(content).sort()
124+
}
125+
90126
function authorization(username: string, password: string) {
91127
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
92128
}
@@ -110,15 +146,15 @@ afterEach(async () => {
110146
describe("HttpApi server", () => {
111147
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
112148
const honoRoutes = openApiRouteKeys(await Server.openapi())
113-
const effectRoutes = openApiRouteKeys(OpenApi.fromApi(PublicApi))
149+
const effectRoutes = openApiRouteKeys(effectOpenApi())
114150

115151
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
116152
expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([])
117153
})
118154

119155
test("matches generated OpenAPI route parameters", async () => {
120156
const hono = openApiParameters(await Server.openapi())
121-
const effect = openApiParameters(OpenApi.fromApi(PublicApi))
157+
const effect = openApiParameters(effectOpenApi())
122158

123159
expect(
124160
Object.keys(hono)
@@ -129,7 +165,7 @@ describe("HttpApi server", () => {
129165

130166
test("matches generated OpenAPI request body shape", async () => {
131167
const hono = openApiRequestBodies(await Server.openapi())
132-
const effect = openApiRequestBodies(OpenApi.fromApi(PublicApi))
168+
const effect = openApiRequestBodies(effectOpenApi())
133169

134170
expect(
135171
Object.keys(hono)
@@ -138,6 +174,38 @@ describe("HttpApi server", () => {
138174
).toEqual([])
139175
})
140176

177+
test("matches SDK-affecting query parameter schemas", async () => {
178+
const effect = effectOpenApi()
179+
180+
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({
181+
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
182+
})
183+
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({
184+
type: "number",
185+
})
186+
expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({
187+
type: "integer",
188+
minimum: 1,
189+
maximum: 200,
190+
})
191+
expect(parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" })).toEqual({
192+
type: "integer",
193+
minimum: 0,
194+
maximum: Number.MAX_SAFE_INTEGER,
195+
})
196+
})
197+
198+
test("documents event routes as server-sent events", () => {
199+
const effect = effectOpenApi()
200+
201+
expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([
202+
"text/event-stream",
203+
])
204+
expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([
205+
"text/event-stream",
206+
])
207+
})
208+
141209
test("allows requests when auth is disabled", async () => {
142210
await using tmp = await tmpdir({ git: true })
143211
await Bun.write(`${tmp.path}/hello.txt`, "hello")

0 commit comments

Comments
 (0)