From f960905b9285a70e8ea73e9cbd0587a8ac608b96 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 02:11:03 +0000 Subject: [PATCH 1/4] fix(openapi): accept single-value enum as literal detailed output status EffectSchemaToJsonSchemaConverter emits `Schema.Literal(201)` as `{ type: 'number', enum: [201] }` rather than `{ const: 201 }`, so `outputStructure: 'detailed'` with an Effect literal status made OpenAPIGenerator.generate() throw 'invalid "status" field'. Treat a single-value `enum` the same as `const` when reading the status. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K42euCFhMfYXSaxkobuBdz --- .../src/openapi-generator-operation.test.ts | 37 +++++++++++++++++++ .../src/openapi-generator-operation.ts | 11 ++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/openapi/src/openapi-generator-operation.test.ts b/packages/openapi/src/openapi-generator-operation.test.ts index 96c4c3b99..f82d36dad 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -614,6 +614,33 @@ describe('openAPIGenerator operation builders', () => { }) }) + it('accepts a single-value enum as a literal detailed status', () => { + const { ctx, operation } = createContext() + + // Effect emits `Schema.Literal(201)` as `{ type: 'number', enum: [201] }` + buildSuccessResponse(ctx, operation, testDef({ + outputs: [testSchema({ + type: 'object', + properties: { + status: { type: 'number', enum: [201], description: 'created' }, + body: { type: 'string' }, + }, + required: ['status', 'body'], + })], + }), { outputStructure: 'detailed' }) + + expect(operation.responses).toEqual({ + 201: { + description: 'created', + content: { + 'application/json': { + schema: { type: 'string' }, + }, + }, + }, + }) + }) + it.each([ { name: 'a detailed output member is not an object', @@ -635,6 +662,16 @@ describe('openAPIGenerator operation builders', () => { output: { type: 'object', properties: { status: { const: 400 } }, required: ['status'] }, message: 'invalid "status" field in the detailed output schema', }, + { + name: 'a detailed status is a multi-value enum', + output: { type: 'object', properties: { status: { type: 'number', enum: [200, 201] } }, required: ['status'] }, + message: 'invalid "status" field in the detailed output schema', + }, + { + name: 'a detailed single-value enum status is not a success status', + output: { type: 'object', properties: { status: { type: 'number', enum: [400] } }, required: ['status'] }, + message: 'invalid "status" field in the detailed output schema', + }, ])('throws when $name', ({ output, message }) => { const { ctx, operation } = createContext() diff --git a/packages/openapi/src/openapi-generator-operation.ts b/packages/openapi/src/openapi-generator-operation.ts index d88095f35..8bfecb053 100644 --- a/packages/openapi/src/openapi-generator-operation.ts +++ b/packages/openapi/src/openapi-generator-operation.ts @@ -411,15 +411,20 @@ function extractDetailedResponseParts( const statusSchema = entries.find(([name]) => name === 'status')?.[1] - if (statusSchema !== undefined && (typeof statusSchema !== 'object' || !Number.isInteger(statusSchema.const) || statusSchema.const >= 400)) { + // Some converters (e.g. Effect) emit literals as a single-value `enum` instead of `const` + const literalStatus = typeof statusSchema === 'object' + ? statusSchema.const ?? (statusSchema.enum?.length === 1 ? statusSchema.enum[0] : undefined) + : undefined + + if (statusSchema !== undefined && (typeof statusSchema !== 'object' || !Number.isInteger(literalStatus) || literalStatus >= 400)) { throw new OpenAPIGeneratorError( `invalid "status" field in the detailed output schema.\n` - + ` Expected: a literal (const) integer below 400\n` + + ` Expected: a literal (const or single-value enum) integer below 400\n` + ` Received: ${stringifyJSON(statusSchema)}`, ) } - const status = (statusSchema?.const as number || undefined) ?? defaultStatus + const status = (literalStatus as number || undefined) ?? defaultStatus let parts = partsByStatus.get(status) if (!parts) { From ba7e07b9cbae927c5ae0dfed9e337235a3f69a86 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:25:36 +0000 Subject: [PATCH 2/4] feat(openapi): support multi-value enum and literal unions for detailed output status A detailed output `status` may now be any union of literal statuses: a multi-value `enum` (zod `z.literal([200, 201])`, valibot `picklist`, arktype `'200 | 201'`, Effect `Schema.Literals`) or an `anyOf` of `const`/`enum` schemas (zod/valibot unions of literals). The member's headers and body are documented under each status, and a description on an individual union member applies only to its own statuses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K42euCFhMfYXSaxkobuBdz --- .../docs/openapi/input-and-output-mapping.mdx | 2 + .../src/openapi-generator-operation.test.ts | 102 +++++++++++++++++- .../src/openapi-generator-operation.ts | 74 +++++++++---- 3 files changed, 153 insertions(+), 25 deletions(-) diff --git a/apps/content/docs/openapi/input-and-output-mapping.mdx b/apps/content/docs/openapi/input-and-output-mapping.mdx index c227d40a1..5665c6378 100644 --- a/apps/content/docs/openapi/input-and-output-mapping.mdx +++ b/apps/content/docs/openapi/input-and-output-mapping.mdx @@ -165,6 +165,8 @@ In `detailed` mode, return an object with the following fields: - `headers`: optional response headers in lower-case keys - `body`: optional response body +For OpenAPI generation, `status` must be a literal or a union of literals. A union like `z.literal([200, 201])` documents the same `headers` and `body` under each status. + ```ts const savePlanet = os .meta(openapi({ diff --git a/packages/openapi/src/openapi-generator-operation.test.ts b/packages/openapi/src/openapi-generator-operation.test.ts index f82d36dad..9936486f5 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -641,6 +641,90 @@ describe('openAPIGenerator operation builders', () => { }) }) + it('maps every value of a multi-value enum detailed status to its own response', () => { + const { ctx, operation } = createContext() + + buildSuccessResponse(ctx, operation, testDef({ + outputs: [testSchema({ + anyOf: [ + { + type: 'object', + properties: { + status: { type: 'number', enum: [200, 201, 200], description: 'success' }, + headers: { type: 'object', properties: { 'x-id': { type: 'string' } }, required: ['x-id'] }, + body: { type: 'string' }, + }, + required: ['status', 'headers', 'body'], + }, + { + type: 'object', + properties: { + status: { const: 201 }, + body: { type: 'number' }, + }, + required: ['status', 'body'], + }, + ], + })], + }), { outputStructure: 'detailed' }) + + expect(operation.responses).toEqual({ + 200: { + description: 'success', + headers: { + 'x-id': { required: true, schema: { type: 'string' } }, + }, + content: { + 'application/json': { + schema: { type: 'string' }, + }, + }, + }, + 201: { + description: 'success', + headers: { + 'x-id': { required: true, schema: { type: 'string' } }, + }, + content: { + 'application/json': { + schema: { anyOf: [{ type: 'string' }, { type: 'number' }] }, + }, + }, + }, + }) + }) + + it('maps a union of literal detailed statuses with per-member descriptions', () => { + const { ctx, operation } = createContext() + + // zod and valibot emit a union of literals as `anyOf` of `const` schemas + buildSuccessResponse(ctx, operation, testDef({ + outputs: [testSchema({ + type: 'object', + properties: { + status: { + description: 'accepted', + anyOf: [ + { type: 'number', const: 201, description: 'created' }, + { type: 'number', const: 202 }, + { type: 'number', enum: [203, 204] }, + ], + }, + body: { type: 'string' }, + }, + required: ['status', 'body'], + })], + }), { outputStructure: 'detailed' }) + + const content = { 'application/json': { schema: { type: 'string' } } } + expect(operation.responses).toEqual({ + 201: { description: 'created', content }, + 202: { description: 'accepted', content }, + 203: { description: 'accepted', content }, + 204: { description: 'accepted', content }, + }) + }) + it.each([ { name: 'a detailed output member is not an object', @@ -663,13 +747,23 @@ describe('openAPIGenerator operation builders', () => { message: 'invalid "status" field in the detailed output schema', }, { - name: 'a detailed status is a multi-value enum', - output: { type: 'object', properties: { status: { type: 'number', enum: [200, 201] } }, required: ['status'] }, + name: 'a detailed enum status contains a non-success status', + output: { type: 'object', properties: { status: { type: 'number', enum: [200, 400] } }, required: ['status'] }, + message: 'invalid "status" field in the detailed output schema', + }, + { + name: 'a detailed enum status is empty', + output: { type: 'object', properties: { status: { type: 'number', enum: [] } }, required: ['status'] }, + message: 'invalid "status" field in the detailed output schema', + }, + { + name: 'a detailed status union contains a non-literal member', + output: { type: 'object', properties: { status: { anyOf: [{ const: 200 }, { type: 'number' }] } }, required: ['status'] }, message: 'invalid "status" field in the detailed output schema', }, { - name: 'a detailed single-value enum status is not a success status', - output: { type: 'object', properties: { status: { type: 'number', enum: [400] } }, required: ['status'] }, + name: 'a detailed status union contains a non-integer literal', + output: { type: 'object', properties: { status: { anyOf: [{ const: 200 }, { const: '201' }] } }, required: ['status'] }, message: 'invalid "status" field in the detailed output schema', }, ])('throws when $name', ({ output, message }) => { diff --git a/packages/openapi/src/openapi-generator-operation.ts b/packages/openapi/src/openapi-generator-operation.ts index 8bfecb053..3e925b7e5 100644 --- a/packages/openapi/src/openapi-generator-operation.ts +++ b/packages/openapi/src/openapi-generator-operation.ts @@ -410,44 +410,76 @@ function extractDetailedResponseParts( } const statusSchema = entries.find(([name]) => name === 'status')?.[1] + const statuses = statusSchema === undefined + ? new Map([[defaultStatus, undefined]]) + : extractDetailedStatuses(statusSchema) - // Some converters (e.g. Effect) emit literals as a single-value `enum` instead of `const` - const literalStatus = typeof statusSchema === 'object' - ? statusSchema.const ?? (statusSchema.enum?.length === 1 ? statusSchema.enum[0] : undefined) - : undefined - - if (statusSchema !== undefined && (typeof statusSchema !== 'object' || !Number.isInteger(literalStatus) || literalStatus >= 400)) { + if (!statuses) { throw new OpenAPIGeneratorError( `invalid "status" field in the detailed output schema.\n` - + ` Expected: a literal (const or single-value enum) integer below 400\n` + + ` Expected: literal integers below 400 (const, enum, or a union of them)\n` + ` Received: ${stringifyJSON(statusSchema)}`, ) } - const status = (literalStatus as number || undefined) ?? defaultStatus + const headersSchema = entries.find(([name]) => name === 'headers')?.[1] + const bodySchema = entries.find(([name]) => name === 'body')?.[1] + + for (const [status, description] of statuses) { + let parts = partsByStatus.get(status) + if (!parts) { + parts = { descriptions: [], bodies: [], headers: [] } + partsByStatus.set(status, parts) + } + + if (description !== undefined) { + parts.descriptions.push(description) + } - let parts = partsByStatus.get(status) - if (!parts) { - parts = { descriptions: [], bodies: [], headers: [] } - partsByStatus.set(status, parts) + if (headersSchema !== undefined) { + parts.headers.push(headersSchema) + } + + if (bodySchema !== undefined) { + parts.bodies.push(bodySchema) + } } + } + + return partsByStatus +} + +/** + * Collects every status a detailed output `status` schema allows, mapped to its description. + * Accepts `const`, `enum` (how Effect, arktype, and valibot `picklist` emit literals), or a union of them. + * Returns `undefined` when any member is not a literal integer below 400. + */ +function extractDetailedStatuses(schema: JsonSchema): Map | undefined { + const statuses = new Map() - if (statusSchema?.description !== undefined) { - parts.descriptions.push(statusSchema.description) + for (const member of flattenJsonUnionSchema(schema)) { + if (typeof member !== 'object') { + return undefined } - const headersSchema = entries.find(([name]) => name === 'headers')?.[1] - if (headersSchema !== undefined) { - parts.headers.push(headersSchema) + const values = member.const !== undefined ? [member.const] : member.enum + + if (!values?.length) { + return undefined } - const bodySchema = entries.find(([name]) => name === 'body')?.[1] - if (bodySchema !== undefined) { - parts.bodies.push(bodySchema) + for (const value of values) { + if (!Number.isInteger(value) || value >= 400) { + return undefined + } + + if (!statuses.has(value)) { + statuses.set(value, member.description) + } } } - return partsByStatus + return statuses.size ? statuses : undefined } export function buildErrorResponse( From 129d8ebcdcc89a3506248a0738b9a8d96b8edd42 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:29:31 +0000 Subject: [PATCH 3/4] test(openapi): consolidate detailed status literal tests into a table Fold the single-value enum, multi-value enum, and literal union cases into one `it.each` table, rename the stale "not a const integer" case, and cover an empty status union. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K42euCFhMfYXSaxkobuBdz --- .../src/openapi-generator-operation.test.ts | 135 +++++------------- 1 file changed, 39 insertions(+), 96 deletions(-) diff --git a/packages/openapi/src/openapi-generator-operation.test.ts b/packages/openapi/src/openapi-generator-operation.test.ts index 9936486f5..3a60a25bb 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -614,115 +614,53 @@ describe('openAPIGenerator operation builders', () => { }) }) - it('accepts a single-value enum as a literal detailed status', () => { - const { ctx, operation } = createContext() - - // Effect emits `Schema.Literal(201)` as `{ type: 'number', enum: [201] }` - buildSuccessResponse(ctx, operation, testDef({ - outputs: [testSchema({ - type: 'object', - properties: { - status: { type: 'number', enum: [201], description: 'created' }, - body: { type: 'string' }, - }, - required: ['status', 'body'], - })], - }), { outputStructure: 'detailed' }) - - expect(operation.responses).toEqual({ - 201: { - description: 'created', - content: { - 'application/json': { - schema: { type: 'string' }, - }, - }, - }, - }) - }) - - it('maps every value of a multi-value enum detailed status to its own response', () => { - const { ctx, operation } = createContext() - - buildSuccessResponse(ctx, operation, testDef({ - outputs: [testSchema({ + it.each([ + { + // Effect emits `Schema.Literal(201)` as `{ type: 'number', enum: [201] }` + name: 'a single-value enum', + status: { type: 'number', enum: [201], description: 'created' }, + descriptions: { 201: 'created' }, + }, + { + name: 'a multi-value enum', + status: { type: 'number', enum: [200, 201, 200], description: 'success' }, + descriptions: { 200: 'success', 201: 'success' }, + }, + { + // zod and valibot emit a union of literals as `anyOf` of `const` schemas + name: 'a union of literals', + status: { + description: 'accepted', anyOf: [ - { - type: 'object', - properties: { - status: { type: 'number', enum: [200, 201, 200], description: 'success' }, - headers: { type: 'object', properties: { 'x-id': { type: 'string' } }, required: ['x-id'] }, - body: { type: 'string' }, - }, - required: ['status', 'headers', 'body'], - }, - { - type: 'object', - properties: { - status: { const: 201 }, - body: { type: 'number' }, - }, - required: ['status', 'body'], - }, + { type: 'number', const: 201, description: 'created' }, + { type: 'number', const: 202 }, + { type: 'number', enum: [203, 204] }, ], - })], - }), { outputStructure: 'detailed' }) - - expect(operation.responses).toEqual({ - 200: { - description: 'success', - headers: { - 'x-id': { required: true, schema: { type: 'string' } }, - }, - content: { - 'application/json': { - schema: { type: 'string' }, - }, - }, }, - 201: { - description: 'success', - headers: { - 'x-id': { required: true, schema: { type: 'string' } }, - }, - content: { - 'application/json': { - schema: { anyOf: [{ type: 'string' }, { type: 'number' }] }, - }, - }, - }, - }) - }) - - it('maps a union of literal detailed statuses with per-member descriptions', () => { + descriptions: { 201: 'created', 202: 'accepted', 203: 'accepted', 204: 'accepted' }, + }, + ])('maps $name detailed status to per-status responses', ({ status, descriptions }) => { const { ctx, operation } = createContext() - // zod and valibot emit a union of literals as `anyOf` of `const` schemas buildSuccessResponse(ctx, operation, testDef({ outputs: [testSchema({ type: 'object', properties: { - status: { - description: 'accepted', - anyOf: [ - { type: 'number', const: 201, description: 'created' }, - { type: 'number', const: 202 }, - { type: 'number', enum: [203, 204] }, - ], - }, + status: status as any, + headers: { type: 'object', properties: { 'x-id': { type: 'string' } }, required: ['x-id'] }, body: { type: 'string' }, }, - required: ['status', 'body'], + required: ['status', 'headers', 'body'], })], }), { outputStructure: 'detailed' }) - const content = { 'application/json': { schema: { type: 'string' } } } - expect(operation.responses).toEqual({ - 201: { description: 'created', content }, - 202: { description: 'accepted', content }, - 203: { description: 'accepted', content }, - 204: { description: 'accepted', content }, - }) + expect(operation.responses).toEqual(Object.fromEntries( + Object.entries(descriptions).map(([code, description]) => [code, { + description, + headers: { 'x-id': { required: true, schema: { type: 'string' } } }, + content: { 'application/json': { schema: { type: 'string' } } }, + }]), + )) }) it.each([ @@ -737,7 +675,7 @@ describe('openAPIGenerator operation builders', () => { message: 'invalid "status" field in the detailed output schema', }, { - name: 'a detailed status is not a const integer', + name: 'a detailed status is not a literal integer', output: { type: 'object', properties: { status: { type: 'number' } }, required: ['status'] }, message: 'invalid "status" field in the detailed output schema', }, @@ -756,6 +694,11 @@ describe('openAPIGenerator operation builders', () => { output: { type: 'object', properties: { status: { type: 'number', enum: [] } }, required: ['status'] }, message: 'invalid "status" field in the detailed output schema', }, + { + name: 'a detailed status union is empty', + output: { type: 'object', properties: { status: { anyOf: [] } }, required: ['status'] }, + message: 'invalid "status" field in the detailed output schema', + }, { name: 'a detailed status union contains a non-literal member', output: { type: 'object', properties: { status: { anyOf: [{ const: 200 }, { type: 'number' }] } }, required: ['status'] }, From 14495752c053fcbb009a22ddecb4d4851d53ccda Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 03:42:26 +0000 Subject: [PATCH 4/4] docs(openapi): drop detailed status literal note Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K42euCFhMfYXSaxkobuBdz --- apps/content/docs/openapi/input-and-output-mapping.mdx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/content/docs/openapi/input-and-output-mapping.mdx b/apps/content/docs/openapi/input-and-output-mapping.mdx index 5665c6378..c227d40a1 100644 --- a/apps/content/docs/openapi/input-and-output-mapping.mdx +++ b/apps/content/docs/openapi/input-and-output-mapping.mdx @@ -165,8 +165,6 @@ In `detailed` mode, return an object with the following fields: - `headers`: optional response headers in lower-case keys - `body`: optional response body -For OpenAPI generation, `status` must be a literal or a union of literals. A union like `z.literal([200, 201])` documents the same `headers` and `body` under each status. - ```ts const savePlanet = os .meta(openapi({