From dac81cdb6853d357500ffe08d2ae0e0571396f3b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 19 May 2026 11:42:01 +0530 Subject: [PATCH] fix(httpapi): preserve v2 openapi errors (#28298) --- .../server/routes/instance/httpapi/public.ts | 44 +++++++--- .../server/httpapi-public-openapi.test.ts | 88 +++++++++++++++++++ 2 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-public-openapi.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 12d3791ecc..5684171837 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -99,17 +99,13 @@ function matchLegacyOpenApi(input: Record) { 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) { 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) { 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"] diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts new file mode 100644 index 0000000000..ba415f2abc --- /dev/null +++ b/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 +} +type OpenApiOperation = { + readonly responses?: Record + readonly security?: unknown +} +type OpenApiPathItem = Partial> +type OpenApiSpec = { readonly paths: Record } + +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) + }) +})