Kaynağa Gözat

fix(httpapi): preserve v2 openapi errors (#28298)

Shoubhit Dash 3 ay önce
ebeveyn
işleme
dac81cdb68

+ 34 - 10
packages/opencode/src/server/routes/instance/httpapi/public.ts

@@ -99,17 +99,13 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
   applyLegacySchemaOverrides(spec)
   normalizeComponentDescriptions(spec)
   addLegacyErrorSchemas(spec)
-  delete spec.components?.schemas?.Unauthorized
-  delete spec.components?.schemas?.EffectHttpApiErrorBadRequest
-  delete spec.components?.schemas?.EffectHttpApiErrorNotFound
-  delete spec.components?.schemas?.effect_HttpApiError_BadRequest
-  delete spec.components?.schemas?.effect_HttpApiError_NotFound
   delete spec.components?.securitySchemes
 
   for (const [path, item] of Object.entries(spec.paths ?? {})) {
     for (const method of ["get", "post", "put", "delete", "patch"] as const) {
       const operation = item[method]
       if (!operation) continue
+      const isV2Api = isV2ApiPath(path)
       if (operation.requestBody) {
         // The legacy OpenAPI surface never marked request bodies as required.
         // Keep that SDK surface stable while the HttpApi spec is tightened.
@@ -146,11 +142,14 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
           if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema))
         }
       }
-      // Auth is still runtime middleware outside the public OpenAPI metadata, so
-      // the SDK should not expose auth schemes or generated 401 error unions.
-      delete operation.security
-      delete operation.responses?.["401"]
-      normalizeLegacyErrorResponses(operation)
+      if (!isV2Api) {
+        // Auth is still runtime middleware outside the legacy public OpenAPI
+        // metadata, so the legacy SDK should not expose auth schemes or
+        // generated 401 error unions.
+        delete operation.security
+        delete operation.responses?.["401"]
+        normalizeLegacyErrorResponses(operation)
+      }
       normalizeLegacyOperation(operation, path, method)
       if ((path === "/event" || path === "/global/event") && method === "get") {
         // HttpApi has no first-class SSE response schema, and these handlers are
@@ -171,9 +170,14 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
       for (const param of operation.parameters ?? []) normalizeParameter(param, route)
     }
   }
+  deleteUnusedLegacyErrorComponents(spec)
   return input
 }
 
+function isV2ApiPath(path: string) {
+  return path === "/api" || path.startsWith("/api/")
+}
+
 function addLegacyErrorSchemas(spec: OpenApiSpec) {
   if (!spec.components?.schemas) return
   spec.components.schemas.BadRequestError = {
@@ -345,6 +349,26 @@ function normalizeLegacyErrorResponses(operation: OpenApiOperation) {
   }
 }
 
+function deleteUnusedLegacyErrorComponents(spec: OpenApiSpec) {
+  for (const name of [
+    "Unauthorized",
+    "EffectHttpApiErrorBadRequest",
+    "EffectHttpApiErrorNotFound",
+    "effect_HttpApiError_BadRequest",
+    "effect_HttpApiError_NotFound",
+  ]) {
+    if (referencesComponent(spec.paths, name)) continue
+    delete spec.components?.schemas?.[name]
+  }
+}
+
+function referencesComponent(input: unknown, name: string): boolean {
+  if (Array.isArray(input)) return input.some((item) => referencesComponent(item, name))
+  if (!input || typeof input !== "object") return false
+  if ((input as OpenApiSchema).$ref === `#/components/schemas/${name}`) return true
+  return Object.values(input).some((value) => referencesComponent(value, name))
+}
+
 function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) {
   if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"]
   if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"]

+ 88 - 0
packages/opencode/test/server/httpapi-public-openapi.test.ts

@@ -0,0 +1,88 @@
+import { describe, expect, test } from "bun:test"
+import { OpenApi } from "effect/unstable/httpapi"
+import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
+
+type Method = "get" | "post" | "put" | "delete" | "patch"
+type OpenApiSchema = { readonly $ref?: string }
+type OpenApiResponse = {
+  readonly description?: string
+  readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
+}
+type OpenApiOperation = {
+  readonly responses?: Record<string, OpenApiResponse>
+  readonly security?: unknown
+}
+type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
+type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
+
+const methods = ["get", "post", "put", "delete", "patch"] as const
+
+const allowedV2BuiltInEndpointErrors = [
+  "GET /api/session 400 effect_HttpApiError_BadRequest",
+  "GET /api/session/{sessionID}/message 400 effect_HttpApiError_BadRequest",
+]
+
+function v2Operations(spec: OpenApiSpec) {
+  return Object.entries(spec.paths).flatMap(([path, item]) =>
+    path.startsWith("/api/")
+      ? methods.flatMap((method) => {
+          const operation = item[method]
+          return operation ? [{ method, path, operation }] : []
+        })
+      : [],
+  )
+}
+
+function responseRef(response: OpenApiResponse | undefined) {
+  return response?.content?.["application/json"]?.schema?.$ref
+}
+
+function componentName(ref: string) {
+  return ref.replace("#/components/schemas/", "")
+}
+
+function isBuiltInEndpointError(name: string) {
+  return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_")
+}
+
+describe("PublicApi OpenAPI v2 errors", () => {
+  test("preserves /api auth responses", () => {
+    const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
+
+    for (const route of v2Operations(spec)) {
+      expect(route.operation.responses?.["401"], `${route.method.toUpperCase()} ${route.path}`).toBeDefined()
+      expect(route.operation.security, `${route.method.toUpperCase()} ${route.path}`).toEqual([])
+    }
+  })
+
+  test("does not rewrite /api endpoint errors to legacy error components", () => {
+    const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
+    const refs = v2Operations(spec)
+      .flatMap((route) =>
+        Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
+          const ref = responseRef(response)
+          return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : []
+        }),
+      )
+      .filter((entry) => entry.includes("BadRequestError") || entry.includes("NotFoundError"))
+
+    expect(refs).toEqual(["GET /api/provider/{providerID} 404 NotFoundError"])
+  })
+
+  test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => {
+    const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
+    const builtInEndpointErrors = v2Operations(spec)
+      .flatMap((route) =>
+        Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
+          if (status === "401") return []
+          const ref = responseRef(response)
+          if (!ref) return []
+          const name = componentName(ref)
+          return isBuiltInEndpointError(name) ? [`${route.method.toUpperCase()} ${route.path} ${status} ${name}`] : []
+        }),
+      )
+      .sort()
+
+    expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors)
+  })
+})