From 323f6776b09c388e312f1a50fc1d94875d1a642e Mon Sep 17 00:00:00 2001 From: mrl5 <31549762+mrl5@users.noreply.github.com> Date: Sun, 7 Nov 2021 21:00:12 +0100 Subject: [PATCH 1/3] feat(spec): allow defining custom media type of responses problem description: currently `application/json` is hardcoded into generated spec proposed solution: allow specifying custom media type in generated spec on both controller level by: - using new `@Produces` decorator and on method level by: - using new `@Produces` decorator - introducing new optional arg for `@SuccessResponse` decorator - introducing new optional arg for `@Response` decorator - setting `Content-Type` header in `@Res` decorator impact: - cli: controller generator - cli: method generator - cli: parameter generator - cli: spec2 generator - cli: spec3 generator - runtime: response decorators Closes #1126 Related #968 --- .gitignore | 1 + .../metadataGeneration/controllerGenerator.ts | 18 ++++++++ .../src/metadataGeneration/methodGenerator.ts | 25 ++++++++++- .../metadataGeneration/parameterGenerator.ts | 16 +++++++- packages/cli/src/swagger/specGenerator2.ts | 25 +++++++---- packages/cli/src/swagger/specGenerator3.ts | 17 ++++---- packages/cli/src/utils/swaggerUtils.ts | 3 ++ packages/runtime/src/decorators/response.ts | 15 ++++++- .../runtime/src/metadataGeneration/tsoa.ts | 3 ++ .../controllers/mediaTypeController.ts | 41 +++++++++++++++++++ tests/unit/swagger/schemaDetails.spec.ts | 27 ++++++++++++ tests/unit/swagger/schemaDetails3.spec.ts | 37 +++++++++++++++++ 12 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/controllers/mediaTypeController.ts diff --git a/.gitignore b/.gitignore index 70212ced2..4c8eee9da 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ node_modules routes.ts customRoutes.ts .idea/ +*.swp yarn-error.log tsconfig.tsbuildinfo diff --git a/packages/cli/src/metadataGeneration/controllerGenerator.ts b/packages/cli/src/metadataGeneration/controllerGenerator.ts index 16dc74936..890e30c3d 100644 --- a/packages/cli/src/metadataGeneration/controllerGenerator.ts +++ b/packages/cli/src/metadataGeneration/controllerGenerator.ts @@ -13,6 +13,7 @@ export class ControllerGenerator { private readonly security?: Tsoa.Security[]; private readonly isHidden?: boolean; private readonly commonResponses: Tsoa.Response[]; + private readonly produces?: string; constructor(private readonly node: ts.ClassDeclaration, private readonly current: MetadataGenerator) { this.path = this.getPath(); @@ -20,6 +21,7 @@ export class ControllerGenerator { this.security = this.getSecurity(); this.isHidden = this.getIsHidden(); this.commonResponses = this.getCommonResponses(); + this.produces = this.getProduces(); } public IsValid() { @@ -41,6 +43,7 @@ export class ControllerGenerator { methods: this.buildMethods(), name: this.node.name.text, path: this.path || '', + produces: this.produces, }; } @@ -132,4 +135,19 @@ export class ControllerGenerator { return true; } + + private getProduces(): string | undefined { + const producesDecorators = getDecorators(this.node, identifier => identifier.text === 'Produces'); + + if (!producesDecorators || !producesDecorators.length) { + return; + } + if (producesDecorators.length > 1) { + throw new GenerateMetadataError(`Only one Produces decorator allowed in '${this.node.name!.text}' class.`); + } + + const [decorator] = producesDecorators; + const [produces] = getDecoratorValues(decorator, this.current.typeChecker); + return produces; + } } diff --git a/packages/cli/src/metadataGeneration/methodGenerator.ts b/packages/cli/src/metadataGeneration/methodGenerator.ts index 61584a9c5..48c2e9246 100644 --- a/packages/cli/src/metadataGeneration/methodGenerator.ts +++ b/packages/cli/src/metadataGeneration/methodGenerator.ts @@ -15,6 +15,7 @@ import { getHeaderType } from '../utils/headerTypeHelpers'; export class MethodGenerator { private method: 'options' | 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'; private path: string; + private produces?: string; constructor( private readonly node: ts.MethodDeclaration, @@ -62,6 +63,7 @@ export class MethodGenerator { operationId: this.getOperationId(), parameters, path: this.path, + produces: this.produces, responses, successStatus: successStatus, security: this.getSecurity(), @@ -134,6 +136,22 @@ export class MethodGenerator { // todo: what if someone has multiple no argument methods of the same type in a single controller? // we need to throw an error there this.path = getPath(decorator, this.current.typeChecker); + this.produces = this.getProduces(); + } + + private getProduces(): string | undefined { + const producesDecorators = this.getDecoratorsByIdentifier(this.node, 'Produces'); + + if (!producesDecorators || !producesDecorators.length) { + return; + } + if (producesDecorators.length > 1) { + throw new GenerateMetadataError(`Only one Produces decorator in '${this.getCurrentLocation()}' method, Found: ${producesDecorators.map(d => d.text).join(', ')}`); + } + + const [decorator] = producesDecorators; + const [produces] = getDecoratorValues(decorator, this.current.typeChecker); + return produces; } private getMethodResponses(): Tsoa.Response[] { @@ -145,12 +163,13 @@ export class MethodGenerator { return decorators.map(decorator => { const expression = decorator.parent as ts.CallExpression; - const [name, description, example] = getDecoratorValues(decorator, this.current.typeChecker); + const [name, description, example, produces] = getDecoratorValues(decorator, this.current.typeChecker); return { description: description || '', examples: example === undefined ? undefined : [example], name: name || '200', + produces, schema: expression.typeArguments && expression.typeArguments.length > 0 ? new TypeResolver(expression.typeArguments[0], this.current).resolve() : undefined, headers: getHeaderType(expression.typeArguments, 1, this.current), } as Tsoa.Response; @@ -167,6 +186,7 @@ export class MethodGenerator { description: isVoidType(type) ? 'No content' : description, examples: this.getMethodSuccessExamples(), name: isVoidType(type) ? '204' : '200', + produces: this.produces, schema: type, }, }; @@ -175,7 +195,7 @@ export class MethodGenerator { throw new GenerateMetadataError(`Only one SuccessResponse decorator allowed in '${this.getCurrentLocation()}' method.`); } - const [name, description] = getDecoratorValues(decorators[0], this.current.typeChecker); + const [name, description, produces] = getDecoratorValues(decorators[0], this.current.typeChecker); const examples = this.getMethodSuccessExamples(); const expression = decorators[0].parent as ts.CallExpression; @@ -186,6 +206,7 @@ export class MethodGenerator { description: description || '', examples, name: name || '200', + produces, schema: type, headers, }, diff --git a/packages/cli/src/metadataGeneration/parameterGenerator.ts b/packages/cli/src/metadataGeneration/parameterGenerator.ts index 6b2e13659..52291649a 100644 --- a/packages/cli/src/metadataGeneration/parameterGenerator.ts +++ b/packages/cli/src/metadataGeneration/parameterGenerator.ts @@ -86,6 +86,8 @@ export class ParameterGenerator { return (tsType.getFlags() & ts.TypeFlags.NumberLiteral) !== 0; }; + const headers = getHeaderType(typeNode.typeArguments, 2, this.current); + return statusArgumentTypes.map(statusArgumentType => { if (!isNumberLiteralType(statusArgumentType)) { throw new GenerateMetadataError('@Res() requires the type to be TsoaResponse', parameter); @@ -99,6 +101,7 @@ export class ParameterGenerator { description: this.getParameterDescription(parameter) || '', in: 'res', name: status, + produces: headers ? this.getProducesFromResHeaders(headers) : undefined, parameterName, examples, required: true, @@ -106,12 +109,23 @@ export class ParameterGenerator { exampleLabels, schema: type, validators: {}, - headers: getHeaderType(typeNode.typeArguments, 2, this.current), + headers, deprecated: this.getParameterDeprecation(parameter), }; }); } + private getProducesFromResHeaders(headers: Tsoa.HeaderType): string | undefined { + const { properties } = headers; + const [contentTypeProp] = (properties || []).filter(p => p.name.toLowerCase() === 'content-type' && p.type.dataType === 'enum'); + if (contentTypeProp) { + const type = contentTypeProp.type as Tsoa.EnumType; + const [produces] = type.enums as string[]; + return produces; + } + return; + } + private getBodyPropParameter(parameter: ts.ParameterDeclaration): Tsoa.Parameter { const parameterName = (parameter.name as ts.Identifier).text; const type = this.getValidatedType(parameter); diff --git a/packages/cli/src/swagger/specGenerator2.ts b/packages/cli/src/swagger/specGenerator2.ts index c9218fdc7..ac7b7ac0e 100644 --- a/packages/cli/src/swagger/specGenerator2.ts +++ b/packages/cli/src/swagger/specGenerator2.ts @@ -2,7 +2,7 @@ import { ExtendedSpecConfig } from '../cli'; import { Tsoa, assertNever, Swagger } from '@tsoa/runtime'; import { isVoidType } from '../utils/isVoidType'; import { convertColonPathParams, normalisePath } from './../utils/pathUtils'; -import { getValue } from './../utils/swaggerUtils'; +import { DEFAULT_REQUEST_MEDIA_TYPE, DEFAULT_RESPONSE_MEDIA_TYPE, getValue } from './../utils/swaggerUtils'; import { SpecGenerator } from './specGenerator'; import { UnspecifiedObject } from '../utils/unspecifiedObject'; @@ -14,13 +14,13 @@ export class SpecGenerator2 extends SpecGenerator { public GetSpec() { let spec: Swagger.Spec2 = { basePath: normalisePath(this.config.basePath as string, '/', undefined, false), - consumes: ['application/json'], + consumes: [DEFAULT_REQUEST_MEDIA_TYPE], definitions: this.buildDefinitions(), info: { title: '', }, paths: this.buildPaths(), - produces: ['application/json'], + produces: [DEFAULT_RESPONSE_MEDIA_TYPE], swagger: '2.0', }; @@ -146,15 +146,15 @@ export class SpecGenerator2 extends SpecGenerator { let path = normalisePath(`${normalisedControllerPath}${normalisedMethodPath}`, '/', '', false); path = convertColonPathParams(path); paths[path] = paths[path] || {}; - this.buildMethod(controller.name, method, paths[path]); + this.buildMethod(controller.name, method, paths[path], controller.produces); }); }); return paths; } - private buildMethod(controllerName: string, method: Tsoa.Method, pathObject: any) { - const pathMethod: Swagger.Operation = (pathObject[method.method] = this.buildOperation(controllerName, method)); + private buildMethod(controllerName: string, method: Tsoa.Method, pathObject: any, defaultProduces?: string) { + const pathMethod: Swagger.Operation = (pathObject[method.method] = this.buildOperation(controllerName, method, defaultProduces)); pathMethod.description = method.description; pathMethod.summary = method.summary; pathMethod.tags = method.tags; @@ -187,21 +187,23 @@ export class SpecGenerator2 extends SpecGenerator { method.extensions.forEach(ext => (pathMethod[ext.key] = ext.value)); } - protected buildOperation(controllerName: string, method: Tsoa.Method): Swagger.Operation { + protected buildOperation(controllerName: string, method: Tsoa.Method, defaultProduces?: string): Swagger.Operation { const swaggerResponses: { [name: string]: Swagger.Response } = {}; + let produces: Array = []; method.responses.forEach((res: Tsoa.Response) => { swaggerResponses[res.name] = { description: res.description, }; if (res.schema && !isVoidType(res.schema)) { + produces.push(res.produces); 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; + swaggerResponses[res.name].examples = { [DEFAULT_RESPONSE_MEDIA_TYPE]: res.examples[0] } as Swagger.Example; } if (res.headers) { @@ -220,9 +222,14 @@ export class SpecGenerator2 extends SpecGenerator { } }); + produces = Array.from(new Set(produces.filter(p => p))); + if (produces.length === 0) { + produces = [defaultProduces || DEFAULT_RESPONSE_MEDIA_TYPE]; + } + const operation: Swagger.Operation = { operationId: this.getOperationId(method.name), - produces: ['application/json'], + produces: produces as string[], responses: swaggerResponses, }; diff --git a/packages/cli/src/swagger/specGenerator3.ts b/packages/cli/src/swagger/specGenerator3.ts index d8073a45d..f2558fe1d 100644 --- a/packages/cli/src/swagger/specGenerator3.ts +++ b/packages/cli/src/swagger/specGenerator3.ts @@ -2,7 +2,7 @@ import { ExtendedSpecConfig } from '../cli'; import { Tsoa, assertNever, Swagger } from '@tsoa/runtime'; import { isVoidType } from '../utils/isVoidType'; import { convertColonPathParams, normalisePath } from './../utils/pathUtils'; -import { getValue } from './../utils/swaggerUtils'; +import { DEFAULT_REQUEST_MEDIA_TYPE, DEFAULT_RESPONSE_MEDIA_TYPE, getValue } from './../utils/swaggerUtils'; import { SpecGenerator } from './specGenerator'; import { UnspecifiedObject } from '../utils/unspecifiedObject'; @@ -239,15 +239,15 @@ export class SpecGenerator3 extends SpecGenerator { let path = normalisePath(`${normalisedControllerPath}${normalisedMethodPath}`, '/', '', false); path = convertColonPathParams(path); paths[path] = paths[path] || {}; - this.buildMethod(controller.name, method, paths[path]); + this.buildMethod(controller.name, method, paths[path], controller.produces); }); }); return paths; } - private buildMethod(controllerName: string, method: Tsoa.Method, pathObject: any) { - const pathMethod: Swagger.Operation3 = (pathObject[method.method] = this.buildOperation(controllerName, method)); + private buildMethod(controllerName: string, method: Tsoa.Method, pathObject: any, defaultProduces?: string) { + const pathMethod: Swagger.Operation3 = (pathObject[method.method] = this.buildOperation(controllerName, method, defaultProduces)); pathMethod.description = method.description; pathMethod.summary = method.summary; pathMethod.tags = method.tags; @@ -289,7 +289,7 @@ export class SpecGenerator3 extends SpecGenerator { method.extensions.forEach(ext => (pathMethod[ext.key] = ext.value)); } - protected buildOperation(controllerName: string, method: Tsoa.Method): Swagger.Operation3 { + protected buildOperation(controllerName: string, method: Tsoa.Method, defaultProduces?: string): Swagger.Operation3 { const swaggerResponses: { [name: string]: Swagger.Response3 } = {}; method.responses.forEach((res: Tsoa.Response) => { @@ -298,8 +298,9 @@ export class SpecGenerator3 extends SpecGenerator { }; if (res.schema && !isVoidType(res.schema)) { + const produces = res.produces || defaultProduces || DEFAULT_RESPONSE_MEDIA_TYPE; swaggerResponses[res.name].content = { - 'application/json': { + [produces]: { schema: this.getSwaggerType(res.schema), } as Swagger.Schema3, }; @@ -311,7 +312,7 @@ export class SpecGenerator3 extends SpecGenerator { return { ...acc, [exampleLabel === undefined ? `Example ${exampleCounter++}` : exampleLabel]: { value: ex } }; }, {}); /* eslint-disable @typescript-eslint/dot-notation */ - (swaggerResponses[res.name].content || {})['application/json']['examples'] = examples; + (swaggerResponses[res.name].content || {})[DEFAULT_RESPONSE_MEDIA_TYPE]['examples'] = examples; } } @@ -379,7 +380,7 @@ export class SpecGenerator3 extends SpecGenerator { description: parameter.description, required: parameter.required, content: { - 'application/json': mediaType, + [DEFAULT_REQUEST_MEDIA_TYPE]: mediaType, }, }; diff --git a/packages/cli/src/utils/swaggerUtils.ts b/packages/cli/src/utils/swaggerUtils.ts index c1ebb64f0..fdeebf4b1 100644 --- a/packages/cli/src/utils/swaggerUtils.ts +++ b/packages/cli/src/utils/swaggerUtils.ts @@ -1,3 +1,6 @@ +export const DEFAULT_REQUEST_MEDIA_TYPE = 'application/json'; +export const DEFAULT_RESPONSE_MEDIA_TYPE = 'application/json'; + export function getValue(type: 'string' | 'number' | 'integer' | 'boolean', member: any) { if (member === null) { return null; diff --git a/packages/runtime/src/decorators/response.ts b/packages/runtime/src/decorators/response.ts index ad52f99b8..4fb6de133 100644 --- a/packages/runtime/src/decorators/response.ts +++ b/packages/runtime/src/decorators/response.ts @@ -1,7 +1,7 @@ import { IsValidHeader } from '../utils/isHeaderType'; import { HttpStatusCodeLiteral, HttpStatusCodeStringLiteral, OtherValidOpenApiHttpStatusCode } from '../interfaces/response'; -export function SuccessResponse = {}>(name: string | number, description?: string): Function { +export function SuccessResponse = {}>(name: string | number, description?: string, produces?: string): Function { return () => { return; }; @@ -11,6 +11,7 @@ export function Response { return; @@ -27,3 +28,15 @@ export function Res(): Function { return; }; } + +/** + * Overrides the default media type of response. + * Can be used on controller level or only for specific method + * + * @link https://swagger.io/docs/specification/media-types/ + */ +export function Produces(value: string): Function { + return () => { + return; + }; +} diff --git a/packages/runtime/src/metadataGeneration/tsoa.ts b/packages/runtime/src/metadataGeneration/tsoa.ts index 276b33d9e..c40e290a9 100644 --- a/packages/runtime/src/metadataGeneration/tsoa.ts +++ b/packages/runtime/src/metadataGeneration/tsoa.ts @@ -11,6 +11,7 @@ export namespace Tsoa { methods: Method[]; name: string; path: string; + produces?: string; } export interface Method { @@ -21,6 +22,7 @@ export namespace Tsoa { name: string; parameters: Parameter[]; path: string; + produces?: string; type: Type; tags?: string[]; responses: Response[]; @@ -71,6 +73,7 @@ export namespace Tsoa { export interface Response { description: string; name: string; + produces?: string; schema?: Type; examples?: unknown[]; exampleLabels?: Array; diff --git a/tests/fixtures/controllers/mediaTypeController.ts b/tests/fixtures/controllers/mediaTypeController.ts new file mode 100644 index 000000000..5151560d0 --- /dev/null +++ b/tests/fixtures/controllers/mediaTypeController.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Get, Produces, Path, Post, Response, Res, Route, SuccessResponse, TsoaResponse } from '@tsoa/runtime'; +import { ErrorResponseModel, UserResponseModel } from '../../fixtures/testModel'; + +type UserRequestModel = Pick; + +@Route('MediaTypeTest') +@Produces('application/vnd.mycompany.myapp+json') +export class MediaTypeTestController extends Controller { + @Response('404', 'Not Found') + @Get('Default/{userId}') + public async getDefaultProduces(@Path() userId: number): Promise { + this.setHeader('Content-Type', 'application/vnd.mycompany.myapp+json'); + return Promise.resolve({ + id: userId, + name: 'foo', + }); + } + + @Get('Custom/security.txt') + @Produces('text/plain') + public async getCustomProduces(): Promise { + const securityTxt = 'Contact: mailto: security@example.com\nExpires: 2012-12-12T12:37:00.000Z'; + this.setHeader('Content-Type', 'text/plain'); + return securityTxt; + } + + @SuccessResponse('202', 'Accepted', 'application/vnd.mycompany.myapp.v2+json') + @Response('400', 'Bad Request', undefined, 'application/problem+json') + @Post('Custom') + async postCustomProduces(@Body() model: UserRequestModel, @Res() conflictRes: TsoaResponse<409, { message: string }, { 'Content-Type': 'application/problem+json' }>): Promise { + if (model.name === 'bar') { + this.setHeader('Content-Type', 'application/problem+json'); + return conflictRes?.(409, { message: 'Conflict' }); + } + + const body = { id: model.name.length, name: model.name }; + this.setStatus(202); + this.setHeader('Content-Type', 'application/vnd.mycompany.myapp.v2+json'); // express and hapi returns it, koa ignores + return body; + } +} diff --git a/tests/unit/swagger/schemaDetails.spec.ts b/tests/unit/swagger/schemaDetails.spec.ts index 54f553835..b90a54bb7 100644 --- a/tests/unit/swagger/schemaDetails.spec.ts +++ b/tests/unit/swagger/schemaDetails.spec.ts @@ -427,6 +427,33 @@ describe('Schema details generation', () => { }); }); + describe('media types', () => { + let mediaTypeTest; + + before(() => { + const metadata = new MetadataGenerator('./fixtures/controllers/mediaTypeController.ts').Generate(); + mediaTypeTest = new SpecGenerator2(metadata, getDefaultExtendedOptions()).GetSpec(); + }); + + it('Should use controller Produces decorator as a default media type', () => { + const { produces } = mediaTypeTest.paths['/MediaTypeTest/Default/{userId}']?.get; + + expect(produces).to.deep.eq(['application/vnd.mycompany.myapp+json']); + }); + + it('Should generate custom media type from method Produces decorator', () => { + const { produces } = mediaTypeTest.paths['/MediaTypeTest/Custom/security.txt']?.get; + + expect(produces).to.deep.eq(['text/plain']); + }); + + it('Should generate custom media types from method reponse decorators and Res decorator', () => { + const { produces } = mediaTypeTest.paths['/MediaTypeTest/Custom']?.post; + + expect(produces).to.deep.eq(['application/problem+json', 'application/vnd.mycompany.myapp.v2+json']); + }); + }); + it('Falls back to the first @Example<>', () => { const metadata = new MetadataGenerator('./fixtures/controllers/exampleController.ts').Generate(); const exampleSpec = new SpecGenerator2(metadata, getDefaultExtendedOptions()).GetSpec(); diff --git a/tests/unit/swagger/schemaDetails3.spec.ts b/tests/unit/swagger/schemaDetails3.spec.ts index ae57319cb..22ad317c8 100644 --- a/tests/unit/swagger/schemaDetails3.spec.ts +++ b/tests/unit/swagger/schemaDetails3.spec.ts @@ -683,6 +683,43 @@ describe('Definition generation for OpenAPI 3.0.0', () => { }); }); + describe('media types', () => { + let mediaTypeTest; + + before(() => { + const metadata = new MetadataGenerator('./fixtures/controllers/mediaTypeController.ts').Generate(); + mediaTypeTest = new SpecGenerator3(metadata, getDefaultExtendedOptions()).GetSpec(); + }); + + it('Should use controller Produces decorator as a default media type', () => { + const [mediaTypeOk] = Object.keys(mediaTypeTest.paths['/MediaTypeTest/Default/{userId}']?.get?.responses?.[200]?.content); + const [mediaTypeNotFound] = Object.keys(mediaTypeTest.paths['/MediaTypeTest/Default/{userId}']?.get?.responses?.[404]?.content); + + expect(mediaTypeOk).to.eql('application/vnd.mycompany.myapp+json'); + expect(mediaTypeNotFound).to.eql('application/vnd.mycompany.myapp+json'); + }); + + it('Should generate custom media type from method Produces decorator', () => { + const [mediaType] = Object.keys(mediaTypeTest.paths['/MediaTypeTest/Custom/security.txt']?.get?.responses?.[200]?.content); + + expect(mediaType).to.eql('text/plain'); + }); + + it('Should generate custom media types from method reponse decorators', () => { + const [mediaTypeAccepted] = Object.keys(mediaTypeTest.paths['/MediaTypeTest/Custom']?.post?.responses?.[202]?.content); + const [mediaTypeBadRequest] = Object.keys(mediaTypeTest.paths['/MediaTypeTest/Custom']?.post?.responses?.[400]?.content); + + expect(mediaTypeAccepted).to.eql('application/vnd.mycompany.myapp.v2+json'); + expect(mediaTypeBadRequest).to.eql('application/problem+json'); + }); + + it('Should generate custom media types from header in @Res decorator', () => { + const [mediaTypeConflict] = Object.keys(mediaTypeTest.paths['/MediaTypeTest/Custom']?.post?.responses?.[409]?.content); + + expect(mediaTypeConflict).to.eql('application/problem+json'); + }); + }); + it('Supports multiple examples', () => { const metadata = new MetadataGenerator('./fixtures/controllers/exampleController.ts').Generate(); const exampleSpec = new SpecGenerator3(metadata, getDefaultExtendedOptions()).GetSpec(); From 3ba054991f463c2cc53df705b9041e4344e6a24a Mon Sep 17 00:00:00 2001 From: mrl5 <31549762+mrl5@users.noreply.github.com> Date: Sun, 14 Nov 2021 17:56:01 +0100 Subject: [PATCH 2/3] fix(koa): dont overwrite content-type of response problem description: as described in https://github.com/koajs/koa/issues/1120 currently `koa` always overwrites `Content-Type` for JSON responses. proposed solution: workaround discovered by @brunoabreu is to set headers after setting the body. Closes #1134 Related #1129 --- packages/cli/src/routeGeneration/templates/koa.hbs | 3 +-- tests/fixtures/controllers/mediaTypeController.ts | 2 +- tests/fixtures/express/server.ts | 1 + tests/fixtures/hapi/server.ts | 1 + tests/fixtures/koa/server.ts | 1 + tests/integration/express-server.spec.ts | 13 +++++++++++++ tests/integration/hapi-server.spec.ts | 13 +++++++++++++ tests/integration/koa-server.spec.ts | 13 +++++++++++++ 8 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/routeGeneration/templates/koa.hbs b/packages/cli/src/routeGeneration/templates/koa.hbs index 6716837cb..b2d2ee9b2 100644 --- a/packages/cli/src/routeGeneration/templates/koa.hbs +++ b/packages/cli/src/routeGeneration/templates/koa.hbs @@ -204,8 +204,6 @@ export function RegisterRoutes(router: KoaRouter) { function returnHandler(context: any, next: () => any, statusCode?: number, data?: any, headers: any={}) { if (!context.headerSent && !context.response.__tsoaResponded) { - context.set(headers); - if (data !== null && data !== undefined) { context.body = data; context.status = 200; @@ -217,6 +215,7 @@ export function RegisterRoutes(router: KoaRouter) { context.status = statusCode; } + context.set(headers); context.response.__tsoaResponded = true; return next(); } diff --git a/tests/fixtures/controllers/mediaTypeController.ts b/tests/fixtures/controllers/mediaTypeController.ts index 5151560d0..b037d702b 100644 --- a/tests/fixtures/controllers/mediaTypeController.ts +++ b/tests/fixtures/controllers/mediaTypeController.ts @@ -35,7 +35,7 @@ export class MediaTypeTestController extends Controller { const body = { id: model.name.length, name: model.name }; this.setStatus(202); - this.setHeader('Content-Type', 'application/vnd.mycompany.myapp.v2+json'); // express and hapi returns it, koa ignores + this.setHeader('Content-Type', 'application/vnd.mycompany.myapp.v2+json'); return body; } } diff --git a/tests/fixtures/express/server.ts b/tests/fixtures/express/server.ts index 8ccb28449..3c4f56961 100644 --- a/tests/fixtures/express/server.ts +++ b/tests/fixtures/express/server.ts @@ -12,6 +12,7 @@ import '../controllers/postController'; import '../controllers/putController'; import '../controllers/methodController'; +import '../controllers/mediaTypeController'; import '../controllers/parameterController'; import '../controllers/securityController'; import '../controllers/testController'; diff --git a/tests/fixtures/hapi/server.ts b/tests/fixtures/hapi/server.ts index a76448dba..9b3e816f4 100644 --- a/tests/fixtures/hapi/server.ts +++ b/tests/fixtures/hapi/server.ts @@ -9,6 +9,7 @@ import '../controllers/postController'; import '../controllers/putController'; import '../controllers/methodController'; +import '../controllers/mediaTypeController'; import '../controllers/parameterController'; import '../controllers/securityController'; import '../controllers/testController'; diff --git a/tests/fixtures/koa/server.ts b/tests/fixtures/koa/server.ts index 6c5117a42..12f9b0254 100644 --- a/tests/fixtures/koa/server.ts +++ b/tests/fixtures/koa/server.ts @@ -11,6 +11,7 @@ import '../controllers/postController'; import '../controllers/putController'; import '../controllers/methodController'; +import '../controllers/mediaTypeController'; import '../controllers/parameterController'; import '../controllers/securityController'; import '../controllers/testController'; diff --git a/tests/integration/express-server.spec.ts b/tests/integration/express-server.spec.ts index 9eae328bb..334b9b80f 100644 --- a/tests/integration/express-server.spec.ts +++ b/tests/integration/express-server.spec.ts @@ -460,6 +460,19 @@ describe('Express Server', () => { }); }); + describe('Custom Content-Type', () => { + it('should return custom content-type if given', () => { + return verifyPostRequest( + basePath + '/MediaTypeTest/Custom', + { name: 'foo' }, + (err, res) => { + expect(res.type).to.eq('application/vnd.mycompany.myapp.v2+json'); + }, + 202, + ); + }); + }); + describe('Validate', () => { it('should valid minDate and maxDate validation of date type', () => { const minDate = '2019-01-01'; diff --git a/tests/integration/hapi-server.spec.ts b/tests/integration/hapi-server.spec.ts index 5d431f8da..fba6d9a98 100644 --- a/tests/integration/hapi-server.spec.ts +++ b/tests/integration/hapi-server.spec.ts @@ -357,6 +357,19 @@ describe('Hapi Server', () => { }); }); + describe('Custom Content-Type', () => { + it('should return custom content-type if given', () => { + return verifyPostRequest( + basePath + '/MediaTypeTest/Custom', + { name: 'foo' }, + (err, res) => { + expect(res.type).to.eq('application/vnd.mycompany.myapp.v2+json'); + }, + 202, + ); + }); + }); + describe('Validate', () => { it('should valid minDate and maxDate validation of date type', () => { const minDate = '2019-01-01'; diff --git a/tests/integration/koa-server.spec.ts b/tests/integration/koa-server.spec.ts index cffcec0b9..4e9c92096 100644 --- a/tests/integration/koa-server.spec.ts +++ b/tests/integration/koa-server.spec.ts @@ -303,6 +303,19 @@ describe('Koa Server', () => { }); }); + describe('Custom Content-Type', () => { + it('should return custom content-type if given', () => { + return verifyPostRequest( + basePath + '/MediaTypeTest/Custom', + { name: 'foo' }, + (err, res) => { + expect(res.type).to.eq('application/vnd.mycompany.myapp.v2+json'); + }, + 202, + ); + }); + }); + describe('NoExtends', () => { it('should ignore SuccessResponse code and use default code', () => { return verifyGetRequest( From 68b0906a3200b866dc7e4b324b6c5f7e25b82c23 Mon Sep 17 00:00:00 2001 From: mrl5 <31549762+mrl5@users.noreply.github.com> Date: Sun, 14 Nov 2021 21:53:08 +0100 Subject: [PATCH 3/3] refactor(swagger2): ensure only undefined is filtered out suggested by @WoH during code review Co-authored-by: Wolfgang Hobmaier --- packages/cli/src/swagger/specGenerator2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/swagger/specGenerator2.ts b/packages/cli/src/swagger/specGenerator2.ts index ac7b7ac0e..1047ac891 100644 --- a/packages/cli/src/swagger/specGenerator2.ts +++ b/packages/cli/src/swagger/specGenerator2.ts @@ -222,7 +222,7 @@ export class SpecGenerator2 extends SpecGenerator { } }); - produces = Array.from(new Set(produces.filter(p => p))); + produces = Array.from(new Set(produces.filter(p => p !== undefined))); if (produces.length === 0) { produces = [defaultProduces || DEFAULT_RESPONSE_MEDIA_TYPE]; }