Skip to content

Commit 0b498dd

Browse files
authored
fix(httpapi): preserve OpenAPI parameter parity (#25291)
1 parent cec9c61 commit 0b498dd

3 files changed

Lines changed: 67 additions & 13 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Instance Route Parity
2+
3+
This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned.
4+
5+
- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported.
6+
- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics.
7+
- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema.
8+
- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress.

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

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type OpenApiSchema = {
3939
maximum?: number
4040
minimum?: number
4141
oneOf?: OpenApiSchema[]
42+
pattern?: string
4243
prefixItems?: OpenApiSchema[]
4344
properties?: Record<string, OpenApiSchema>
4445
required?: string[]
@@ -74,9 +75,18 @@ const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
7475
const QueryBooleanParameters = new Set(["roots", "archived"])
7576
const QueryParameterSchemas = {
7677
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
78+
"GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" },
7779
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
7880
} satisfies Record<string, OpenApiSchema>
7981

82+
const PathParameterSchemas = {
83+
sessionID: { type: "string", pattern: "^ses.*" },
84+
messageID: { type: "string", pattern: "^msg.*" },
85+
partID: { type: "string", pattern: "^prt.*" },
86+
permissionID: { type: "string", pattern: "^per.*" },
87+
ptyID: { type: "string", pattern: "^pty.*" },
88+
} satisfies Record<string, OpenApiSchema>
89+
8090
const LegacyComponentDescriptions = {
8191
LogLevel: "Log level",
8292
ServerConfig: "Server configuration for opencode serve and web commands",
@@ -428,6 +438,11 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) {
428438

429439
/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
430440
function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
441+
if (schema.allOf?.length === 1) {
442+
const [constraint] = schema.allOf
443+
delete schema.allOf
444+
return stripOptionalNull({ ...schema, ...constraint })
445+
}
431446
if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} }
432447
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
433448
if (options) {
@@ -476,25 +491,40 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] |
476491
}
477492

478493
function normalizeParameter(param: OpenApiParameter, route: string) {
479-
if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return
480-
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
481-
if (override) {
482-
param.schema = override
494+
if (!param.schema || typeof param.schema !== "object") return
495+
if (param.in === "path") {
496+
param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema)
483497
return
484498
}
485-
if (QueryNumberParameters.has(param.name)) {
486-
param.schema = { type: "number" }
487-
return
488-
}
489-
if (QueryBooleanParameters.has(param.name)) {
490-
param.schema = {
491-
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
499+
if (param.in === "query") {
500+
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
501+
if (override) {
502+
param.schema = override
503+
return
504+
}
505+
if (QueryNumberParameters.has(param.name)) {
506+
param.schema = { type: "number" }
507+
return
508+
}
509+
if (QueryBooleanParameters.has(param.name)) {
510+
param.schema = {
511+
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
512+
}
513+
return
492514
}
493-
return
494515
}
495516
param.schema = stripOptionalNull(param.schema)
496517
}
497518

519+
function pathParameterSchema(route: string, name: string) {
520+
if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas]
521+
if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
522+
if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
523+
if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" }
524+
if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" }
525+
return undefined
526+
}
527+
498528
export const PublicApi = OpenCodeHttpApi.annotateMerge(
499529
OpenApi.annotations({
500530
title: "opencode",

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,23 @@ type RequestBody = {
119119
function parameterKey(param: unknown): string | undefined {
120120
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
121121
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
122-
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
122+
return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema(
123+
"schema" in param ? param.schema : undefined,
124+
)}`
125+
}
126+
127+
function stableSchema(input: unknown): string {
128+
return JSON.stringify(sortSchema(input))
129+
}
130+
131+
function sortSchema(input: unknown): unknown {
132+
if (Array.isArray(input)) return input.map(sortSchema)
133+
if (!input || typeof input !== "object") return input
134+
return Object.fromEntries(
135+
Object.entries(input)
136+
.sort(([left], [right]) => left.localeCompare(right))
137+
.map(([key, value]) => [key, sortSchema(value)]),
138+
)
123139
}
124140

125141
function parameterSchema(input: {

0 commit comments

Comments
 (0)