fix(httpapi): preserve OpenAPI parameter parity (#25291)
This commit is contained in:
8
packages/opencode/src/server/routes/instance/AGENTS.md
Normal file
8
packages/opencode/src/server/routes/instance/AGENTS.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Instance Route Parity
|
||||||
|
|
||||||
|
This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned.
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress.
|
||||||
@@ -39,6 +39,7 @@ type OpenApiSchema = {
|
|||||||
maximum?: number
|
maximum?: number
|
||||||
minimum?: number
|
minimum?: number
|
||||||
oneOf?: OpenApiSchema[]
|
oneOf?: OpenApiSchema[]
|
||||||
|
pattern?: string
|
||||||
prefixItems?: OpenApiSchema[]
|
prefixItems?: OpenApiSchema[]
|
||||||
properties?: Record<string, OpenApiSchema>
|
properties?: Record<string, OpenApiSchema>
|
||||||
required?: string[]
|
required?: string[]
|
||||||
@@ -74,9 +75,18 @@ const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
|
|||||||
const QueryBooleanParameters = new Set(["roots", "archived"])
|
const QueryBooleanParameters = new Set(["roots", "archived"])
|
||||||
const QueryParameterSchemas = {
|
const QueryParameterSchemas = {
|
||||||
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
|
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
|
||||||
|
"GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" },
|
||||||
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||||
} satisfies Record<string, OpenApiSchema>
|
} satisfies Record<string, OpenApiSchema>
|
||||||
|
|
||||||
|
const PathParameterSchemas = {
|
||||||
|
sessionID: { type: "string", pattern: "^ses.*" },
|
||||||
|
messageID: { type: "string", pattern: "^msg.*" },
|
||||||
|
partID: { type: "string", pattern: "^prt.*" },
|
||||||
|
permissionID: { type: "string", pattern: "^per.*" },
|
||||||
|
ptyID: { type: "string", pattern: "^pty.*" },
|
||||||
|
} satisfies Record<string, OpenApiSchema>
|
||||||
|
|
||||||
const LegacyComponentDescriptions = {
|
const LegacyComponentDescriptions = {
|
||||||
LogLevel: "Log level",
|
LogLevel: "Log level",
|
||||||
ServerConfig: "Server configuration for opencode serve and web commands",
|
ServerConfig: "Server configuration for opencode serve and web commands",
|
||||||
@@ -428,6 +438,11 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) {
|
|||||||
|
|
||||||
/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
|
/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
|
||||||
function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
|
function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
|
||||||
|
if (schema.allOf?.length === 1) {
|
||||||
|
const [constraint] = schema.allOf
|
||||||
|
delete schema.allOf
|
||||||
|
return stripOptionalNull({ ...schema, ...constraint })
|
||||||
|
}
|
||||||
if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} }
|
if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} }
|
||||||
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
|
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
|
||||||
if (options) {
|
if (options) {
|
||||||
@@ -476,25 +491,40 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] |
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeParameter(param: OpenApiParameter, route: string) {
|
function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||||
if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return
|
if (!param.schema || typeof param.schema !== "object") return
|
||||||
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
|
if (param.in === "path") {
|
||||||
if (override) {
|
param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema)
|
||||||
param.schema = override
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (QueryNumberParameters.has(param.name)) {
|
if (param.in === "query") {
|
||||||
param.schema = { type: "number" }
|
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
|
||||||
return
|
if (override) {
|
||||||
}
|
param.schema = override
|
||||||
if (QueryBooleanParameters.has(param.name)) {
|
return
|
||||||
param.schema = {
|
}
|
||||||
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
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
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
param.schema = stripOptionalNull(param.schema)
|
param.schema = stripOptionalNull(param.schema)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pathParameterSchema(route: string, name: string) {
|
||||||
|
if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas]
|
||||||
|
if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
|
||||||
|
if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
|
||||||
|
if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" }
|
||||||
|
if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" }
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
export const PublicApi = OpenCodeHttpApi.annotateMerge(
|
export const PublicApi = OpenCodeHttpApi.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
title: "opencode",
|
title: "opencode",
|
||||||
|
|||||||
@@ -119,7 +119,23 @@ type RequestBody = {
|
|||||||
function parameterKey(param: unknown): string | undefined {
|
function parameterKey(param: unknown): string | undefined {
|
||||||
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
|
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
|
||||||
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
|
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
|
||||||
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
|
return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema(
|
||||||
|
"schema" in param ? param.schema : undefined,
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableSchema(input: unknown): string {
|
||||||
|
return JSON.stringify(sortSchema(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortSchema(input: unknown): unknown {
|
||||||
|
if (Array.isArray(input)) return input.map(sortSchema)
|
||||||
|
if (!input || typeof input !== "object") return input
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(input)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, value]) => [key, sortSchema(value)]),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parameterSchema(input: {
|
function parameterSchema(input: {
|
||||||
|
|||||||
Reference in New Issue
Block a user