mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-21 03:15:11 +00:00
fix(httpapi): preserve v2 openapi errors (#28298)
This commit is contained in:
@@ -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
packages/opencode/test/server/httpapi-public-openapi.test.ts
Normal file
88
packages/opencode/test/server/httpapi-public-openapi.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user