Skip to content

Commit cea1e7f

Browse files
authored
fix(codegen): don't surface error responses as method return types (#674)
1 parent e2af5c8 commit cea1e7f

7 files changed

Lines changed: 322 additions & 480 deletions

File tree

packages/api/src/cli/codegen/languages/typescript.ts

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import type { InstallerOptions } from '../language';
33
import type Oas from 'oas';
44
import type { Operation } from 'oas';
55
import type { HttpMethods, SchemaObject } from 'oas/dist/rmoas.types';
6-
import type { ClassDeclaration, JSDocStructure, OptionalKind, ParameterDeclarationStructure } from 'ts-morph';
6+
import type {
7+
ClassDeclaration,
8+
JSDocStructure,
9+
JSDocTagStructure,
10+
OptionalKind,
11+
ParameterDeclarationStructure,
12+
} from 'ts-morph';
713
import type { PackageJson } from 'type-fest';
814

915
import fs from 'fs';
@@ -28,7 +34,13 @@ interface OperationTypeHousing {
2834
operation: Operation;
2935
types: {
3036
params?: false | Record<'body' | 'formData' | 'metadata', string>;
31-
responses?: Record<string, string>;
37+
responses?: Record<
38+
string | number,
39+
{
40+
description?: string;
41+
type: string;
42+
}
43+
>;
3244
};
3345
}
3446

@@ -502,6 +514,20 @@ sdk.server('https://eu.api.example.com/v14');`)
502514
return sourceFile;
503515
}
504516

517+
/**
518+
* Add a new JSDoc `@tag` to an existing docblock.
519+
*
520+
*/
521+
static addTagToDocblock(docblock: OptionalKind<JSDocStructure>, tag: OptionalKind<JSDocTagStructure>) {
522+
const tags = docblock.tags ?? [];
523+
tags.push(tag);
524+
525+
return {
526+
...docblock,
527+
tags,
528+
};
529+
}
530+
505531
/**
506532
* Create operation accessors on the SDK.
507533
*
@@ -512,7 +538,7 @@ sdk.server('https://eu.api.example.com/v14');`)
512538
paramTypes?: OperationTypeHousing['types']['params'],
513539
responseTypes?: OperationTypeHousing['types']['responses']
514540
) {
515-
const docblock: OptionalKind<JSDocStructure> = {};
541+
let docblock: OptionalKind<JSDocStructure> = {};
516542
const summary = operation.getSummary();
517543
const description = operation.getDescription();
518544
if (summary || description) {
@@ -531,7 +557,10 @@ sdk.server('https://eu.api.example.com/v14');`)
531557
};
532558

533559
if (summary && description) {
534-
docblock.tags = [{ tagName: 'summary', text: docblockEscape(wordWrap(summary)) }];
560+
docblock = TSGenerator.addTagToDocblock(docblock, {
561+
tagName: 'summary',
562+
text: docblockEscape(wordWrap(summary)),
563+
});
535564
}
536565
}
537566

@@ -568,8 +597,8 @@ sdk.server('https://eu.api.example.com/v14');`)
568597

569598
let returnType = 'Promise<FetchResponse<number, unknown>>';
570599
if (responseTypes) {
571-
returnType = `Promise<${Object.entries(responseTypes)
572-
.map(([status, responseType]) => {
600+
const returnTypes = Object.entries(responseTypes)
601+
.map(([status, { description: responseDescription, type: responseType }]) => {
573602
if (status.toLowerCase() === 'default') {
574603
return `FetchResponse<number, ${responseType}>`;
575604
} else if (status.length === 3 && status.toUpperCase().endsWith('XX')) {
@@ -580,19 +609,54 @@ sdk.server('https://eu.api.example.com/v14');`)
580609
return `FetchResponse<number, ${responseType}>`;
581610
}
582611

612+
if (Number(statusPrefix) >= 4) {
613+
docblock = TSGenerator.addTagToDocblock(docblock, {
614+
tagName: 'throws',
615+
text: `FetchError<${status}, ${responseType}>${
616+
responseDescription ? docblockEscape(wordWrap(` ${responseDescription}`)) : ''
617+
}`,
618+
});
619+
620+
return false;
621+
}
622+
583623
this.usesHTTPMethodRangeInterface = true;
584624
return `FetchResponse<HTTPMethodRange<${statusPrefix}00, ${statusPrefix}99>, ${responseType}>`;
585625
}
586626

627+
// 400 and 500 status code families are thrown as exceptions so adding them as a possible
628+
// return type isn't valid.
629+
if (Number(status) >= 400) {
630+
docblock = TSGenerator.addTagToDocblock(docblock, {
631+
tagName: 'throws',
632+
text: `FetchError<${status}, ${responseType}>${
633+
responseDescription ? docblockEscape(wordWrap(` ${responseDescription}`)) : ''
634+
}`,
635+
});
636+
637+
return false;
638+
}
639+
587640
return `FetchResponse<${status}, ${responseType}>`;
588641
})
589-
.join(' | ')}>`;
642+
.filter(Boolean)
643+
.join(' | ');
644+
645+
// If all of our documented responses are for error status codes then all we can document for
646+
// anything else that might happen is `unknown`.
647+
returnType = `Promise<${returnTypes.length ? returnTypes : 'FetchResponse<number, unknown>'}>`;
590648
}
591649

650+
const shouldAddAltTypedOverloads = Object.keys(parameters).length === 2 && hasOptionalBody && !hasOptionalMetadata;
592651
const operationIdAccessor = this.sdk.addMethod({
593652
name: operationId,
594653
returnType,
595-
docs: Object.keys(docblock).length ? [docblock] : null,
654+
655+
// If we're going to be creating typed method overloads for optional body an metadata handling
656+
// we should only add a docblock to the first overload we create because IDE Intellisense will
657+
// always use that and adding a docblock to all three will bloat the SDK with unused and
658+
// unsurfaced method documentation.
659+
docs: shouldAddAltTypedOverloads ? null : Object.keys(docblock).length ? [docblock] : null,
596660
statements: writer => {
597661
/**
598662
* @example return this.core.fetch('/pet/findByStatus', 'get', body, metadata);
@@ -626,10 +690,6 @@ sdk.server('https://eu.api.example.com/v14');`)
626690
// If we have both body and metadata parameters but only body is optional we need to create
627691
// a couple function overloads as Typescript doesn't let us have an optional method parameter
628692
// come before one that's required.
629-
//
630-
// None of these accessor overloads will receive a docblock because the original will have
631-
// that covered.
632-
const shouldAddAltTypedOverloads = Object.keys(parameters).length === 2 && hasOptionalBody && !hasOptionalMetadata;
633693
if (shouldAddAltTypedOverloads) {
634694
// Create an overload that has both `body` and `metadata` parameters as required.
635695
operationIdAccessor.addOverload({
@@ -645,7 +705,6 @@ sdk.server('https://eu.api.example.com/v14');`)
645705
operationIdAccessor.addOverload({
646706
parameters: [{ ...parameters.metadata }],
647707
returnType,
648-
docs: Object.keys(docblock).length ? [docblock] : null,
649708
});
650709

651710
// Create an overload that has both `body` and `metadata` parameters as optional. Even though
@@ -807,7 +866,7 @@ sdk.server('https://eu.api.example.com/v14');`)
807866
.reduce((prev, next) => Object.assign(prev, next));
808867

809868
const res = Object.entries(schemas)
810-
.map(([status, { schema }]) => {
869+
.map(([status, { description, schema }]) => {
811870
let typeName;
812871

813872
if (typeof schema === 'string' && schema.startsWith('::convert::')) {
@@ -826,7 +885,10 @@ sdk.server('https://eu.api.example.com/v14');`)
826885
return {
827886
// Types are prefixed with `types.` because that's how we're importing them from
828887
// `types.d.ts`.
829-
[status]: `types.${typeName}`,
888+
[status]: {
889+
type: `types.${typeName}`,
890+
description,
891+
},
830892
};
831893
})
832894
.reduce((prev, next) => Object.assign(prev, next), {});

packages/api/src/core/errors/fetchError.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
class FetchError extends Error {
1+
class FetchError<Status = number, Data = unknown> extends Error {
22
/** HTTP Status */
3-
status: number;
3+
status: Status;
44

55
/** The content of the response. */
6-
data: unknown;
6+
data: Data;
77

88
/** The Headers of the response. */
99
headers: Headers;
1010

1111
/** The raw `Response` object. */
1212
res: Response;
1313

14-
constructor(status: number, data: unknown, headers: Headers, res: Response) {
14+
constructor(status: Status, data: Data, headers: Headers, res: Response) {
1515
super(res.statusText);
1616

1717
this.name = 'FetchError';

packages/api/src/core/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,12 @@ export default class APICore {
128128
const parsed = await parseResponse(res);
129129

130130
if (res.status >= 400 && res.status <= 599) {
131-
throw new FetchError(parsed.status, parsed.data, parsed.headers, parsed.res);
131+
throw new FetchError<typeof parsed.status, typeof parsed.data>(
132+
parsed.status,
133+
parsed.data,
134+
parsed.headers,
135+
parsed.res
136+
);
132137
}
133138

134139
return parsed;

0 commit comments

Comments
 (0)