Skip to content

Commit

Permalink
Merge pull request #1000 from davidqqq/feat/custom-example-label
Browse files Browse the repository at this point in the history
Feat/custom example label
  • Loading branch information
WoH committed Jun 30, 2021
2 parents 5cbd764 + 13e644b commit 7c05b4d
Show file tree
Hide file tree
Showing 7 changed files with 92 additions and 15 deletions.
42 changes: 28 additions & 14 deletions packages/cli/src/metadataGeneration/parameterGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,16 @@ export class ParameterGenerator {
const status = String(statusArgumentType.value);

const type = new TypeResolver(bodyArgument, this.current, typeNode).resolve();

const { examples, exampleLabels } = this.getParameterExample(parameter, parameterName);
return {
description: this.getParameterDescription(parameter) || '',
in: 'res',
name: status,
parameterName,
examples: this.getParameterExample(parameter, parameterName),
examples,
required: true,
type,
exampleLabels,
schema: type,
validators: {},
headers: getHeaderType(typeNode.typeArguments, 2, this.current),
Expand All @@ -122,7 +123,7 @@ export class ParameterGenerator {
return {
default: getInitializerValue(parameter.initializer, this.current.typeChecker, type),
description: this.getParameterDescription(parameter),
example: this.getParameterExample(parameter, parameterName),
example: this.getParameterExample(parameter, parameterName).examples,
in: 'body-prop',
name: getNodeFirstDecoratorValue(this.parameter, this.current.typeChecker, ident => ident.text === 'BodyProp') || parameterName,
parameterName,
Expand All @@ -145,7 +146,7 @@ export class ParameterGenerator {
description: this.getParameterDescription(parameter),
in: 'body',
name: parameterName,
example: this.getParameterExample(parameter, parameterName),
example: this.getParameterExample(parameter, parameterName).examples,
parameterName,
required: !parameter.questionToken && !parameter.initializer,
type,
Expand All @@ -165,7 +166,7 @@ export class ParameterGenerator {
return {
default: getInitializerValue(parameter.initializer, this.current.typeChecker, type),
description: this.getParameterDescription(parameter),
example: this.getParameterExample(parameter, parameterName),
example: this.getParameterExample(parameter, parameterName).examples,
in: 'header',
name: getNodeFirstDecoratorValue(this.parameter, this.current.typeChecker, ident => ident.text === 'Header') || parameterName,
parameterName,
Expand Down Expand Up @@ -235,7 +236,7 @@ export class ParameterGenerator {
const commonProperties = {
default: getInitializerValue(parameter.initializer, this.current.typeChecker, type),
description: this.getParameterDescription(parameter),
example: this.getParameterExample(parameter, parameterName),
example: this.getParameterExample(parameter, parameterName).examples,
in: 'query' as const,
name: getNodeFirstDecoratorValue(this.parameter, this.current.typeChecker, ident => ident.text === 'Query') || parameterName,
parameterName,
Expand Down Expand Up @@ -289,11 +290,11 @@ export class ParameterGenerator {
if (!this.path.includes(`{${pathName}}`) && !this.path.includes(`:${pathName}`)) {
throw new GenerateMetadataError(`@Path('${parameterName}') Can't match in URL: '${this.path}'.`);
}

const { examples } = this.getParameterExample(parameter, parameterName);
return {
default: getInitializerValue(parameter.initializer, this.current.typeChecker, type),
description: this.getParameterDescription(parameter),
example: this.getParameterExample(parameter, parameterName),
example: examples,
in: 'path',
name: pathName,
parameterName,
Expand Down Expand Up @@ -323,15 +324,28 @@ export class ParameterGenerator {
}

private getParameterExample(node: ts.ParameterDeclaration, parameterName: string) {
const examples = getJSDocTags(node.parent, tag => (tag.tagName.text === 'example' || tag.tagName.escapedText === 'example') && !!tag.comment && tag.comment.startsWith(parameterName)).map(tag =>
(tag.comment || '').replace(`${parameterName} `, '').replace(/\r/g, ''),
);

const exampleLabels: Array<string | undefined> = [];
const examples = getJSDocTags(node.parent, tag => {
const isExample = (tag.tagName.text === 'example' || tag.tagName.escapedText === 'example') && !!tag.comment && tag.comment.startsWith(parameterName);
const hasExampleLabel = (tag.comment?.indexOf('.') || -1) > 0;

if (isExample) {
// custom example label is delimited by first '.' and the rest will all be included as example label
exampleLabels.push(hasExampleLabel ? tag.comment!.split(' ')[0].split('.').slice(1).join('.') : undefined);
}
return isExample;
}).map(tag => (tag.comment || '').replace(`${tag.comment?.split(' ')[0] || ''}`, '').replace(/\r/g, ''));
if (examples.length === 0) {
return undefined;
return {
exmaples: undefined,
exampleLabels: undefined,
};
} else {
try {
return examples.map(example => JSON.parse(example));
return {
examples: examples.map(example => JSON.parse(example)),
exampleLabels,
};
} catch (e) {
throw new GenerateMetadataError(`JSON format is incorrect: ${String(e.message)}`);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/swagger/specGenerator2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ export class SpecGenerator2 extends SpecGenerator {
swaggerResponses[res.name].schema = this.getSwaggerType(res.schema) as Swagger.Schema;
}
if (res.examples && res.examples[0]) {
if ((res.exampleLabels?.filter(e => e).length || 0) > 0) {
console.warn('Example labels are not supported in OpenAPI 2');
}
swaggerResponses[res.name].examples = { 'application/json': res.examples[0] } as Swagger.Example;
}

Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/swagger/specGenerator3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,10 @@ export class SpecGenerator3 extends SpecGenerator {
};

if (res.examples) {
let exampleCounter = 1;
const examples = res.examples.reduce<Swagger.Example['examples']>((acc, ex, currentIndex) => {
return { ...acc, [`Example ${currentIndex + 1}`]: { value: ex } };
const exampleLabel = res.exampleLabels?.[currentIndex];
return { ...acc, [exampleLabel === undefined ? `Example ${exampleCounter++}` : exampleLabel]: { value: ex } };
}, {});
/* eslint-disable @typescript-eslint/dot-notation */
(swaggerResponses[res.name].content || {})['application/json']['examples'] = examples;
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime/src/metadataGeneration/tsoa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export namespace Tsoa {
default?: any;
validators: Validators;
deprecated: boolean;
exampleLabels?: Array<string | undefined>;
}

export interface ResParameter extends Response, Parameter {
Expand Down Expand Up @@ -72,6 +73,7 @@ export namespace Tsoa {
name: string;
schema?: Type;
examples?: unknown[];
exampleLabels?: Array<string | undefined>;
headers?: HeaderType;
}

Expand Down
18 changes: 18 additions & 0 deletions tests/fixtures/controllers/exampleController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,22 @@ export class ExampleTestController {
res?.(400, 123, { 'custom-header': 'hello' });
return 'test 1';
}

/**
* @param res The alternate response
* @example res.NoSuchCountry { "errorMessage":"No such country", "errorCode": 40000 }
* @example res. { "errorMessage":"No custom label", "errorCode": 40000 }
* @example res "Unlabeled 1"
* @example res "Another unlabeled one"
* @example res.NoSuchCity {
* "errorMessage":"No such city",
* "errorCode": 40000
* }
* @example res { "errorMessage":"No custom label", "errorCode": 40000 }
*/
@Get('CustomExampleLabels')
public async customExampleLabels(@Res() res: TsoaResponse<400, number, { 'custom-header': string }>): Promise<string> {
res?.(400, 123, { 'custom-header': 'hello' });
return 'test 1';
}
}
13 changes: 13 additions & 0 deletions tests/unit/swagger/schemaDetails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,19 @@ describe('Schema details generation', () => {

expect(responses?.[200]?.examples?.['application/json']).to.eq('test 1');
});

it('ignores example label in OpenAPI 2 due to lack of support', () => {
const originalWarn = console.warn;
const warningMessages: string[] = [];
const mockedWarn = (output: string) => warningMessages.push(output);
console.warn = mockedWarn;
const metadata = new MetadataGenerator('./fixtures/controllers/exampleController.ts').Generate();
const exampleSpec = new SpecGenerator2(metadata, getDefaultExtendedOptions()).GetSpec();
const examples = exampleSpec.paths['/ExampleTest/CustomExampleLabels']?.get?.responses?.[400]?.examples?.['application/json'];
expect(warningMessages[0]).eq('Example labels are not supported in OpenAPI 2');
expect(examples).not.to.haveOwnProperty('No country');
console.warn = originalWarn;
});
});
});
});
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/swagger/schemaDetails3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,31 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
},
});
});

it('Supports custom example labels', () => {
const metadata = new MetadataGenerator('./fixtures/controllers/exampleController.ts').Generate();
const exampleSpec = new SpecGenerator3(metadata, getDefaultExtendedOptions()).GetSpec();
const examples = exampleSpec.paths['/ExampleTest/CustomExampleLabels']?.get?.responses?.[400]?.content?.['application/json'].examples;

expect(examples).to.deep.eq({
NoSuchCountry: { value: { errorMessage: 'No such country', errorCode: 40000 } },
'': {
value: {
errorCode: 40000,
errorMessage: 'No custom label',
},
},
'Example 1': { value: 'Unlabeled 1' },
'Example 2': { value: 'Another unlabeled one' },
NoSuchCity: { value: { errorMessage: 'No such city', errorCode: 40000 } },
'Example 3': {
value: {
errorCode: 40000,
errorMessage: 'No custom label',
},
},
});
});
});

describe('deprecation', () => {
Expand Down

0 comments on commit 7c05b4d

Please sign in to comment.