فهرست منبع

fix(httpapi): preserve OpenAPI parameter parity (#25291)

Kit Langton 4 ماه پیش
والد
کامیت
0b498dd448

+ 8 - 0
packages/opencode/src/server/routes/instance/AGENTS.md

@@ -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.

+ 42 - 12
packages/opencode/src/server/routes/instance/httpapi/public.ts

@@ -39,6 +39,7 @@ type OpenApiSchema = {
   maximum?: number
   minimum?: number
   oneOf?: OpenApiSchema[]
+  pattern?: string
   prefixItems?: OpenApiSchema[]
   properties?: Record<string, OpenApiSchema>
   required?: string[]
@@ -74,9 +75,18 @@ 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}/diff messageID": { type: "string", pattern: "^msg.*" },
   "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
 } 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 = {
   LogLevel: "Log level",
   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. */
 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: {} }
   const options = flattenOptions(schema.anyOf ?? schema.oneOf)
   if (options) {
@@ -476,25 +491,40 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] |
 }
 
 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
+  if (!param.schema || typeof param.schema !== "object") return
+  if (param.in === "path") {
+    param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema)
     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"] }],
+  if (param.in === "query") {
+    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
     }
-    return
   }
   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(
   OpenApi.annotations({
     title: "opencode",

+ 17 - 1
packages/opencode/test/server/httpapi-bridge.test.ts

@@ -119,7 +119,23 @@ type RequestBody = {
 function parameterKey(param: unknown): string | 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
-  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: {