fix(httpapi): preserve legacy SDK error parity

This commit is contained in:
Kit Langton
2026-04-28 22:16:49 -04:00
parent 80bcc8d478
commit 0033c4474c
3 changed files with 205 additions and 42 deletions
+6 -5
View File
@@ -32,19 +32,20 @@ git -C "$SDK" checkout -- src/ 2>/dev/null
echo "" >&2
if [[ "${1:-}" == "--stat" ]]; then
honly=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep -c '^< export type' || true)
aonly=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep -c '^> export type' || true)
total=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | wc -l | tr -d ' ')
diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true)
honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true)
aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true)
total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ')
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
echo ""
if [[ $honly -gt 0 ]]; then
echo "=== Hono-only types ==="
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep '^< export type' | sed 's/< export type / /' | sed 's/ .*//'
printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /'
echo ""
fi
if [[ $aonly -gt 0 ]]; then
echo "=== HttpApi-only types ==="
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep '^> export type' | sed 's/> export type / /' | sed 's/ .*//'
printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /'
fi
else
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
@@ -129,6 +129,14 @@ Required before route deletion:
- Compare generated SDK output against `dev` for every route group deletion.
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
V2 cleanup once SDK compatibility no longer needs the legacy Hono contract:
- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses.
- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape.
- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks.
- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape.
- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec.
### 5. Make HttpApi Default For JSON Routes
After JSON parity and SDK generation are covered:
@@ -10,11 +10,12 @@ type OpenApiParameter = {
type OpenApiOperation = {
parameters?: OpenApiParameter[]
responses?: Record<string, unknown>
responses?: Record<string, OpenApiResponse>
requestBody?: {
required?: boolean
content?: Record<string, { schema?: OpenApiSchema }>
}
security?: unknown
}
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
@@ -22,6 +23,7 @@ type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch
type OpenApiSpec = {
components?: {
schemas?: Record<string, OpenApiSchema>
securitySchemes?: Record<string, unknown>
}
paths?: Record<string, OpenApiPathItem>
}
@@ -31,16 +33,22 @@ type OpenApiSchema = {
additionalProperties?: OpenApiSchema | boolean
allOf?: OpenApiSchema[]
anyOf?: OpenApiSchema[]
enum?: string[]
enum?: Array<string | boolean>
items?: OpenApiSchema
maximum?: number
minimum?: number
oneOf?: OpenApiSchema[]
prefixItems?: OpenApiSchema[]
properties?: Record<string, OpenApiSchema>
required?: string[]
type?: string
}
type OpenApiResponse = {
description?: string
content?: Record<string, { schema?: OpenApiSchema }>
}
// Instance routes use middleware for directory/workspace resolution, but HttpApi
// doesn't surface middleware query params in the spec. Inject them explicitly.
const InstanceQueryParameters = [
@@ -68,34 +76,6 @@ const QueryParameterSchemas = {
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
} satisfies Record<string, OpenApiSchema>
const LegacyRequestBodySchemas: Record<string, OpenApiSchema> = {
"POST /global/upgrade": { type: "object" },
"POST /log": { type: "object" },
"POST /experimental/workspace": { type: "object" },
"POST /experimental/workspace/{id}/session-restore": { type: "object" },
"PATCH /project/{projectID}": { type: "object" },
"POST /experimental/console/switch": { type: "object" },
"POST /experimental/worktree": { $ref: "#/components/schemas/WorktreeCreateInput" },
"POST /session": { type: "object" },
"PATCH /session/{sessionID}": { type: "object" },
"POST /session/{sessionID}/init": { type: "object" },
"POST /session/{sessionID}/fork": { type: "object" },
"POST /session/{sessionID}/summarize": { type: "object" },
"POST /session/{sessionID}/message": { type: "object" },
"POST /session/{sessionID}/prompt_async": { type: "object" },
"POST /session/{sessionID}/command": { type: "object" },
"POST /session/{sessionID}/shell": { type: "object" },
"POST /session/{sessionID}/revert": { type: "object" },
"POST /session/{sessionID}/permissions/{permissionID}": { type: "object" },
"POST /permission/{requestID}/reply": { type: "object" },
"POST /question/{requestID}/reply": { type: "object" },
"POST /sync/replay": { type: "object" },
"POST /mcp": { type: "object" },
"POST /mcp/{name}/auth/callback": { type: "object" },
"POST /tui/execute-command": { type: "object" },
"POST /tui/publish": {},
}
// Mapping of "METHOD /path" to the correct 200 response description from Hono spec
const ResponseDescriptions = {
"GET /global/health": "Health information",
@@ -212,6 +192,70 @@ const ResponseDescriptions = {
"GET /formatter": "Formatter status",
} as const satisfies Record<string, string>
const LegacyErrorResponses = {
"PUT /auth/{providerID}": [400],
"DELETE /auth/{providerID}": [400],
"POST /log": [400],
"PATCH /global/config": [400],
"POST /global/upgrade": [400],
"POST /experimental/workspace": [400],
"POST /experimental/console/switch": [400],
"DELETE /experimental/workspace/{id}": [400],
"POST /experimental/workspace/{id}/session-restore": [400],
"GET /experimental/tool/ids": [400],
"GET /experimental/tool": [400],
"POST /experimental/worktree": [400],
"DELETE /experimental/worktree": [400],
"POST /experimental/worktree/reset": [400],
"PATCH /config": [400],
"PATCH /project/{projectID}": [400, 404],
"POST /pty": [400],
"GET /pty/{ptyID}": [404],
"PUT /pty/{ptyID}": [400],
"DELETE /pty/{ptyID}": [404],
"GET /pty/{ptyID}/connect": [404],
"POST /session": [400],
"GET /session/status": [400],
"GET /session/{sessionID}": [400, 404],
"DELETE /session/{sessionID}": [400, 404],
"PATCH /session/{sessionID}": [400, 404],
"GET /session/{sessionID}/children": [400, 404],
"GET /session/{sessionID}/todo": [400, 404],
"POST /session/{sessionID}/init": [400, 404],
"POST /session/{sessionID}/abort": [400, 404],
"POST /session/{sessionID}/share": [400, 404],
"DELETE /session/{sessionID}/share": [400, 404],
"POST /session/{sessionID}/summarize": [400, 404],
"GET /session/{sessionID}/message": [400, 404],
"POST /session/{sessionID}/message": [400, 404],
"GET /session/{sessionID}/message/{messageID}": [400, 404],
"DELETE /session/{sessionID}/message/{messageID}": [400, 404],
"DELETE /session/{sessionID}/message/{messageID}/part/{partID}": [400, 404],
"PATCH /session/{sessionID}/message/{messageID}/part/{partID}": [400, 404],
"POST /session/{sessionID}/prompt_async": [400, 404],
"POST /session/{sessionID}/command": [400, 404],
"POST /session/{sessionID}/shell": [400, 404],
"POST /session/{sessionID}/revert": [400, 404],
"POST /session/{sessionID}/unrevert": [400, 404],
"POST /session/{sessionID}/permissions/{permissionID}": [400, 404],
"POST /permission/{requestID}/reply": [400, 404],
"POST /question/{requestID}/reply": [400, 404],
"POST /question/{requestID}/reject": [400, 404],
"POST /provider/{providerID}/oauth/authorize": [400],
"POST /provider/{providerID}/oauth/callback": [400],
"POST /sync/replay": [400],
"POST /sync/history": [400],
"POST /mcp": [400],
"POST /mcp/{name}/auth": [404],
"POST /mcp/{name}/auth/callback": [400, 404],
"POST /mcp/{name}/auth/authenticate": [404],
"DELETE /mcp/{name}/auth": [404],
"POST /tui/append-prompt": [400],
"POST /tui/execute-command": [400],
"POST /tui/publish": [400],
"POST /tui/select-session": [400, 404],
} as const satisfies Record<string, ReadonlyArray<400 | 404>>
function matchLegacyOpenApi(input: Record<string, unknown>) {
const spec = input as OpenApiSpec
@@ -228,6 +272,11 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema))
}
addLegacyErrorSchemas(spec)
delete spec.components?.schemas?.Unauthorized
delete spec.components?.schemas?.EffectHttpApiErrorBadRequest
delete spec.components?.schemas?.EffectHttpApiErrorNotFound
delete spec.components?.securitySchemes
for (const [path, item] of Object.entries(spec.paths ?? {})) {
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
@@ -238,7 +287,6 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
// Hono's generated OpenAPI never marked request bodies as required. Keep
// that SDK surface stable during the HttpApi migration.
delete operation.requestBody.required
normalizeRequestBody(operation, `${method.toUpperCase()} ${path}`)
if (path === "/experimental/workspace" && method === "post") {
// Workspace creation fields `branch` and `extra` are Schema.NullOr —
// genuinely nullable, not just optional. Re-add the null that the
@@ -249,6 +297,11 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
}
}
// Hono applied auth as runtime middleware outside OpenAPI metadata, so the
// legacy SDK did not expose auth schemes or generated 401 error unions.
delete operation.security
delete operation.responses?.["401"]
normalizeLegacyErrorResponses(operation)
if ((path === "/event" || path === "/global/event") && method === "get") {
// HttpApi has no first-class SSE response schema, and these handlers are
// raw/streaming routes. Document the actual wire protocol explicitly.
@@ -256,7 +309,7 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
description: "Event stream",
content: {
"text/event-stream": {
schema: path === "/event" ? {} : { $ref: "#/components/schemas/GlobalEvent" },
schema: path === "/event" ? { $ref: "#/components/schemas/Event" } : { $ref: "#/components/schemas/GlobalEvent" },
},
},
}
@@ -264,6 +317,8 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
// Apply response descriptions from the Hono spec to match legacy behavior
const routeKey = `${method.toUpperCase()} ${path}` as keyof typeof ResponseDescriptions
const responseDescription = ResponseDescriptions[routeKey]
applyLegacyErrorResponses(operation, routeKey)
inlineLegacyResponseSchemas(operation, routeKey)
if (responseDescription && operation.responses?.["200"]) {
const response200 = operation.responses["200"] as { description?: string }
response200.description = responseDescription
@@ -281,11 +336,110 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
return input
}
function normalizeRequestBody(operation: OpenApiOperation, route: string) {
const schema = LegacyRequestBodySchemas[route]
const body = operation.requestBody?.content?.["application/json"]
if (!schema || !body) return
body.schema = structuredClone(schema)
function addLegacyErrorSchemas(spec: OpenApiSpec) {
if (!spec.components?.schemas) return
spec.components.schemas.BadRequestError = {
type: "object",
required: ["data", "errors", "success"],
properties: {
data: {},
errors: {
type: "array",
items: {
type: "object",
additionalProperties: {},
},
},
success: { type: "boolean", enum: [false] },
},
}
spec.components.schemas.NotFoundError = {
type: "object",
required: ["name", "data"],
properties: {
name: { type: "string", enum: ["NotFoundError"] },
data: {
type: "object",
required: ["message"],
properties: {
message: { type: "string" },
},
},
},
}
}
function normalizeLegacyErrorResponses(operation: OpenApiOperation) {
if (operation.responses?.["400"] && isRefResponse(operation.responses["400"], "EffectHttpApiErrorBadRequest")) {
operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError")
}
if (operation.responses?.["404"] && isRefResponse(operation.responses["404"], "EffectHttpApiErrorNotFound")) {
operation.responses["404"] = legacyErrorResponse("Not found", "NotFoundError")
}
}
function applyLegacyErrorResponses(operation: OpenApiOperation, route: string) {
const responses = LegacyErrorResponses[route as keyof typeof LegacyErrorResponses]
if (!responses) return
operation.responses ??= {}
if (responses.includes(400)) operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError")
else if (operation.responses["400"] && isErrorResponse(operation.responses["400"], "BadRequest")) delete operation.responses["400"]
if (responses.includes(404)) operation.responses["404"] = legacyErrorResponse("Not found", "NotFoundError")
else if (operation.responses["404"] && isErrorResponse(operation.responses["404"], "NotFound")) delete operation.responses["404"]
}
function inlineLegacyResponseSchemas(operation: OpenApiOperation, route: string) {
const response = operation.responses?.["200"]
const schema = response?.content?.["application/json"]?.schema
if (!schema) return
if (route === "POST /mcp/{name}/auth") {
response.content!["application/json"]!.schema = {
type: "object",
required: ["authorizationUrl"],
properties: {
authorizationUrl: { type: "string" },
},
}
return
}
if (route === "DELETE /mcp/{name}/auth") {
response.content!["application/json"]!.schema = {
type: "object",
required: ["success"],
properties: {
success: { type: "boolean", enum: [true] },
},
}
return
}
if (route === "POST /sync/replay") {
response.content!["application/json"]!.schema = {
type: "object",
required: ["sessionID"],
properties: {
sessionID: { type: "string" },
},
}
}
}
function isRefResponse(response: OpenApiResponse, name: string) {
return response.content?.["application/json"]?.schema?.$ref === `#/components/schemas/${name}`
}
function isErrorResponse(response: OpenApiResponse, name: "BadRequest" | "NotFound") {
return isRefResponse(response, `EffectHttpApiError${name}`) || isRefResponse(response, `${name}Error`)
}
function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse {
return {
description,
content: {
"application/json": {
schema: { $ref: `#/components/schemas/${name}` },
},
},
}
}
/**