Skip to content

Commit db820c3

Browse files
constantantclaude
andcommitted
feat(openapi-resource-gen): emit typed XxxError type aliases for 4xx/5xx JSON responses
Endpoints that define application/json content on 4xx, 5xx, or default response codes now get an exported `XxxError` type alias alongside the existing `XxxResponse` and `XxxParams` aliases. Multiple error codes produce a union type; the `default` catch-all key is included when present. Specs with no JSON error bodies (e.g. Petstore) emit no error type, keeping output identical to before. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 286aec5 commit db820c3

4 files changed

Lines changed: 144 additions & 0 deletions

File tree

tools/openapi-resource-gen/src/generators/api-resource/endpoint-model.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface EndpointModel {
3333
hasResponse: boolean;
3434
/** All 2xx response codes that carry application/json content, in priority order. */
3535
responseStatuses: string[];
36+
/** 4xx/5xx/default response codes that carry application/json content. */
37+
errorStatuses: string[];
3638
bodyContentType: string | null;
3739
/** True when bodyContentType is not json / form-urlencoded / multipart (e.g. octet-stream, pdf, image). */
3840
isBinaryBody: boolean;

tools/openapi-resource-gen/src/generators/api-resource/generator.spec.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,123 @@ describe('api-resource generator', () => {
623623
});
624624
});
625625

626+
describe('typed error responses', () => {
627+
it('emits a single XxxError type for a single 4xx JSON response', async () => {
628+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
629+
paths: {
630+
'/pets/{id}': {
631+
get: {
632+
operationId: 'getPet',
633+
tags: ['pets'],
634+
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
635+
responses: {
636+
'200': { content: { 'application/json': { schema: {} } } },
637+
'404': { content: { 'application/json': { schema: {} } } },
638+
},
639+
},
640+
},
641+
},
642+
} as never);
643+
644+
await apiResourceGenerator(tree, {
645+
specPath: 'specs/petstore.yaml',
646+
outputDir: 'libs/error-single/src',
647+
});
648+
const content = tree.read('libs/error-single/src/pets/get-pet.token.ts', 'utf-8')!;
649+
expect(content).toContain('export type GetPetError =');
650+
expect(content).toContain("['responses']['404']['content']['application/json']");
651+
expect(content).not.toMatch(/export type GetPetError =\s*\|/);
652+
});
653+
654+
it('emits a union XxxError type for multiple error codes', async () => {
655+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
656+
paths: {
657+
'/pets': {
658+
post: {
659+
operationId: 'createPet',
660+
tags: ['pets'],
661+
requestBody: { content: { 'application/json': { schema: {} } } },
662+
responses: {
663+
'201': { content: { 'application/json': { schema: {} } } },
664+
'400': { content: { 'application/json': { schema: {} } } },
665+
'422': { content: { 'application/json': { schema: {} } } },
666+
},
667+
},
668+
},
669+
},
670+
} as never);
671+
672+
await apiResourceGenerator(tree, {
673+
specPath: 'specs/petstore.yaml',
674+
outputDir: 'libs/error-union/src',
675+
});
676+
const content = tree.read('libs/error-union/src/pets/create-pet.token.ts', 'utf-8')!;
677+
expect(content).toContain('export type CreatePetError =');
678+
expect(content).toContain("['responses']['400']['content']['application/json']");
679+
expect(content).toContain("['responses']['422']['content']['application/json']");
680+
expect(content).toMatch(/\|\s*paths\[.*\['responses'\]\['400'\]/);
681+
});
682+
683+
it('includes the default catch-all response code in the error type', async () => {
684+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
685+
paths: {
686+
'/items': {
687+
get: {
688+
operationId: 'listItems',
689+
tags: ['items'],
690+
responses: {
691+
'200': { content: { 'application/json': { schema: {} } } },
692+
default: { content: { 'application/json': { schema: {} } } },
693+
},
694+
},
695+
},
696+
},
697+
} as never);
698+
699+
await apiResourceGenerator(tree, {
700+
specPath: 'specs/petstore.yaml',
701+
outputDir: 'libs/error-default/src',
702+
});
703+
const content = tree.read('libs/error-default/src/items/list-items.token.ts', 'utf-8')!;
704+
expect(content).toContain('export type ListItemsError =');
705+
expect(content).toContain("['responses']['default']['content']['application/json']");
706+
});
707+
708+
it('does not emit XxxError type when no error responses carry JSON', async () => {
709+
vi.mocked(SwaggerParser.dereference).mockResolvedValue({
710+
paths: {
711+
'/files/{id}': {
712+
delete: {
713+
operationId: 'deleteFile',
714+
tags: ['files'],
715+
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
716+
responses: {
717+
'204': {},
718+
'404': { content: { 'text/plain': { schema: {} } } },
719+
},
720+
},
721+
},
722+
},
723+
} as never);
724+
725+
await apiResourceGenerator(tree, {
726+
specPath: 'specs/petstore.yaml',
727+
outputDir: 'libs/no-error-type/src',
728+
});
729+
const content = tree.read('libs/no-error-type/src/files/delete-file.token.ts', 'utf-8')!;
730+
expect(content).not.toContain('Error =');
731+
});
732+
733+
it('does not emit XxxError type when no error responses are defined', async () => {
734+
await apiResourceGenerator(tree, {
735+
specPath: 'specs/petstore.yaml',
736+
outputDir: 'libs/no-errors/src',
737+
});
738+
const content = tree.read('libs/no-errors/src/pets/list-pets.token.ts', 'utf-8')!;
739+
expect(content).not.toContain('Error =');
740+
});
741+
});
742+
626743
describe('verbose output', () => {
627744
it('prints a file summary when verbose is true', async () => {
628745
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);

tools/openapi-resource-gen/src/generators/api-resource/parse-spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,13 @@ export function buildEndpoints(
162162
});
163163
const hasResponse = responseStatuses.length > 0;
164164

165+
const errorStatuses = Object.keys(operation.responses ?? {})
166+
.filter((code) => code === 'default' || /^[45]/.test(code))
167+
.filter((code) => {
168+
const obj = operation.responses?.[code] as OpenAPIV3.ResponseObject | undefined;
169+
return obj?.content?.['application/json'] != null;
170+
});
171+
165172
const deprecated = operation.deprecated === true;
166173

167174
endpoints.push({
@@ -182,6 +189,7 @@ export function buildEndpoints(
182189
isBinaryBody,
183190
deprecated,
184191
securitySchemeNames,
192+
errorStatuses,
185193
});
186194
}
187195
}

tools/openapi-resource-gen/src/generators/api-resource/render-token.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,23 @@ export function renderTokenFile(
141141
lines.push('');
142142
}
143143
}
144+
if (ep.errorStatuses.length > 0) {
145+
if (ep.errorStatuses.length === 1) {
146+
lines.push(
147+
`export type ${pascal}Error =`,
148+
` paths['${ep.apiPath}']['${ep.method}']['responses']['${ep.errorStatuses[0]}']['content']['application/json'];`,
149+
''
150+
);
151+
} else {
152+
lines.push(`export type ${pascal}Error =`);
153+
for (const code of ep.errorStatuses) {
154+
lines.push(
155+
` | paths['${ep.apiPath}']['${ep.method}']['responses']['${code}']['content']['application/json']`
156+
);
157+
}
158+
lines.push('');
159+
}
160+
}
144161

145162
const responseT = hasResponse ? `${pascal}Response` : 'unknown';
146163
const fnArgs = buildFnArgs(ep, pascal, isGet);

0 commit comments

Comments
 (0)