├── .eslintignore ├── index.ts ├── plugin.ts ├── lib ├── plugin │ ├── index.ts │ ├── plugin-constants.ts │ ├── merge-options.ts │ ├── compiler-plugin.ts │ ├── visitors │ │ ├── abstract.visitor.ts │ │ └── controller-class.visitor.ts │ └── utils │ │ ├── ast-utils.ts │ │ └── plugin-utils.ts ├── utils │ ├── index.ts │ ├── validate-path.util.ts │ ├── is-date-ctor.util.ts │ ├── strip-last-slash.util.ts │ ├── merge-and-uniq.util.ts │ ├── is-body-parameter.util.ts │ ├── is-built-in-type.util.ts │ ├── reverse-object-keys.util.ts │ ├── extend-metadata.util.ts │ ├── get-schema-path.util.ts │ └── enum.utils.ts ├── services │ ├── constants.ts │ ├── mimetype-content-wrapper.ts │ ├── response-object-mapper.ts │ ├── model-properties-accessor.ts │ ├── parameters-metadata-mapper.ts │ ├── parameter-metadata-accessor.ts │ ├── response-object-factory.ts │ └── swagger-types-mapper.ts ├── types │ └── swagger-enum.type.ts ├── decorators │ ├── api-basic.decorator.ts │ ├── api-bearer.decorator.ts │ ├── api-cookie.decorator.ts │ ├── api-oauth2.decorator.ts │ ├── api-hide-property.decorator.ts │ ├── api-use-tags.decorator.ts │ ├── api-consumes.decorator.ts │ ├── api-produces.decorator.ts │ ├── api-extra-models.decorator.ts │ ├── api-exclude-endpoint.decorator.ts │ ├── api-extension.decorator.ts │ ├── api-operation.decorator.ts │ ├── index.ts │ ├── api-security.decorator.ts │ ├── api-body.decorator.ts │ ├── api-param.decorator.ts │ ├── api-query.decorator.ts │ ├── api-property.decorator.ts │ ├── api-header.decorator.ts │ ├── helpers.ts │ └── api-response.decorator.ts ├── type-helpers │ ├── index.ts │ ├── mapped-types.utils.ts │ ├── omit-type.helper.ts │ ├── pick-type.helper.ts │ ├── partial-type.helper.ts │ └── intersection-type.helper.ts ├── interfaces │ ├── index.ts │ ├── denormalized-doc-resolvers.interface.ts │ ├── denormalized-doc.interface.ts │ ├── swagger-custom-options.interface.ts │ ├── schema-object-metadata.interface.ts │ ├── swagger-document-options.interface.ts │ └── open-api-spec.interface.ts ├── index.ts ├── explorers │ ├── api-operation.explorer.ts │ ├── api-exclude-endpoint.explorer.ts │ ├── api-headers.explorer.ts │ ├── api-use-tags.explorer.ts │ ├── api-produces.explorer.ts │ ├── api-consumes.explorer.ts │ ├── api-security.explorer.ts │ ├── api-extra-models.explorer.ts │ ├── api-response.explorer.ts │ └── api-parameters.explorer.ts ├── fixtures │ └── document.base.ts ├── constants.ts ├── swagger-transformer.ts ├── swagger-module.ts ├── swagger-scanner.ts └── document-builder.ts ├── .prettierrc ├── .release-it.json ├── e2e ├── src │ ├── cats │ │ ├── dto │ │ │ ├── tag.dto.ts │ │ │ ├── extra-model.dto.ts │ │ │ ├── pagination-query.dto.ts │ │ │ └── create-cat.dto.ts │ │ ├── cats.module.ts │ │ ├── cats.service.ts │ │ ├── classes │ │ │ └── cat.class.ts │ │ └── cats.controller.ts │ └── app.module.ts ├── jest-e2e.json ├── validate-schema.e2e-spec.ts └── fastify.e2e-spec.ts ├── plugin.js ├── renovate.json ├── jest.config.json ├── .gitignore ├── .npmignore ├── test ├── type-helpers │ ├── type-helpers.test-utils.ts │ ├── omit-type.helper.spec.ts │ ├── pick-type.helper.spec.ts │ ├── intersection-type.helper.spec.ts │ └── partial-type.helper.spec.ts ├── services │ ├── fixtures │ │ ├── create-profile.dto.ts │ │ └── create-user.dto.ts │ ├── model-properties-accessor.spec.ts │ └── schema-object-factory.spec.ts └── plugin │ ├── fixtures │ ├── nullable.dto.ts │ ├── changed-class.dto.ts │ ├── es5-class.dto.ts │ ├── create-cat-alt.dto.ts │ ├── app.controller.ts │ ├── create-cat.dto.ts │ └── create-cat-alt2.dto.ts │ ├── controller-class-visitor.spec.ts │ └── model-class-visitor.spec.ts ├── tsconfig.json ├── .commitlintrc.json ├── .eslintrc.js ├── LICENSE ├── .github ├── lock.yml ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── .circleci └── config.yml ├── package.json ├── README.md └── CONTRIBUTING.md /.eslintignore: -------------------------------------------------------------------------------- 1 | tests/** -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './dist'; 2 | -------------------------------------------------------------------------------- /plugin.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/plugin'; 2 | -------------------------------------------------------------------------------- /lib/plugin/index.ts: -------------------------------------------------------------------------------- 1 | export * from './compiler-plugin'; 2 | -------------------------------------------------------------------------------- /lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-schema-path.util'; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none" 4 | } -------------------------------------------------------------------------------- /lib/services/constants.ts: -------------------------------------------------------------------------------- 1 | export const BUILT_IN_TYPES = [String, Boolean, Number, Object, Array]; 2 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore(): release v${version}" 4 | }, 5 | "github": { 6 | "release": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/types/swagger-enum.type.ts: -------------------------------------------------------------------------------- 1 | export type SwaggerEnumType = 2 | | string[] 3 | | number[] 4 | | (string | number)[] 5 | | Record; 6 | -------------------------------------------------------------------------------- /lib/utils/validate-path.util.ts: -------------------------------------------------------------------------------- 1 | export const validatePath = (inputPath: string): string => 2 | inputPath.charAt(0) !== '/' ? '/' + inputPath : inputPath; 3 | -------------------------------------------------------------------------------- /e2e/src/cats/dto/tag.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '../../../../lib'; 2 | 3 | export class TagDto { 4 | @ApiProperty({ description: 'name' }) 5 | name: string; 6 | } 7 | -------------------------------------------------------------------------------- /lib/decorators/api-basic.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ApiSecurity } from './api-security.decorator'; 2 | 3 | export function ApiBasicAuth(name = 'basic') { 4 | return ApiSecurity(name); 5 | } 6 | -------------------------------------------------------------------------------- /lib/decorators/api-bearer.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ApiSecurity } from './api-security.decorator'; 2 | 3 | export function ApiBearerAuth(name = 'bearer') { 4 | return ApiSecurity(name); 5 | } 6 | -------------------------------------------------------------------------------- /lib/decorators/api-cookie.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ApiSecurity } from './api-security.decorator'; 2 | 3 | export function ApiCookieAuth(name = 'cookie') { 4 | return ApiSecurity(name); 5 | } 6 | -------------------------------------------------------------------------------- /lib/type-helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './intersection-type.helper'; 2 | export * from './omit-type.helper'; 3 | export * from './partial-type.helper'; 4 | export * from './pick-type.helper'; 5 | -------------------------------------------------------------------------------- /lib/utils/is-date-ctor.util.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | 3 | export function isDateCtor(type: Type | Function | string): boolean { 4 | return type === Date; 5 | } 6 | -------------------------------------------------------------------------------- /lib/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export { OpenAPIObject } from './open-api-spec.interface'; 2 | export * from './swagger-custom-options.interface'; 3 | export * from './swagger-document-options.interface'; 4 | -------------------------------------------------------------------------------- /lib/plugin/plugin-constants.ts: -------------------------------------------------------------------------------- 1 | export const OPENAPI_NAMESPACE = 'openapi'; 2 | export const OPENAPI_PACKAGE_NAME = '@nestjs/swagger'; 3 | export const METADATA_FACTORY_NAME = '_OPENAPI_METADATA_FACTORY'; 4 | -------------------------------------------------------------------------------- /lib/utils/strip-last-slash.util.ts: -------------------------------------------------------------------------------- 1 | export function stripLastSlash(path: string): string { 2 | return path && path[path.length - 1] === '/' 3 | ? path.slice(0, path.length - 1) 4 | : path; 5 | } 6 | -------------------------------------------------------------------------------- /plugin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function __export(m) { 3 | for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; 4 | } 5 | exports.__esModule = true; 6 | __export(require('./dist/plugin')); 7 | -------------------------------------------------------------------------------- /e2e/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CatsModule } from './cats/cats.module'; 3 | 4 | @Module({ 5 | imports: [CatsModule] 6 | }) 7 | export class ApplicationModule {} 8 | -------------------------------------------------------------------------------- /lib/interfaces/denormalized-doc-resolvers.interface.ts: -------------------------------------------------------------------------------- 1 | export interface DenormalizedDocResolvers { 2 | root: Function[]; 3 | security: Function[]; 4 | tags: Function[]; 5 | responses: Function[]; 6 | } 7 | -------------------------------------------------------------------------------- /lib/decorators/api-oauth2.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ApiSecurity } from './api-security.decorator'; 2 | 3 | export function ApiOAuth2(scopes: string[], name = 'oauth2') { 4 | return ApiSecurity(name, scopes); 5 | } 6 | -------------------------------------------------------------------------------- /lib/utils/merge-and-uniq.util.ts: -------------------------------------------------------------------------------- 1 | import { merge, uniq } from 'lodash'; 2 | 3 | export function mergeAndUniq(a: unknown = [], b: unknown = []): T { 4 | return (uniq(merge(a, b) as any) as unknown) as T; 5 | } 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "semanticCommits": true, 3 | "packageRules": [{ 4 | "depTypeList": ["devDependencies"], 5 | "automerge": true 6 | }], 7 | "extends": [ 8 | "config:base" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /e2e/src/cats/dto/extra-model.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '../../../../lib'; 2 | 3 | export class ExtraModel { 4 | @ApiProperty() 5 | readonly one: string; 6 | 7 | @ApiProperty() 8 | readonly two: number; 9 | } 10 | -------------------------------------------------------------------------------- /lib/utils/is-body-parameter.util.ts: -------------------------------------------------------------------------------- 1 | import { ParamWithTypeMetadata } from '../services/parameter-metadata-accessor'; 2 | 3 | export function isBodyParameter(param: ParamWithTypeMetadata): boolean { 4 | return param.in === 'body'; 5 | } 6 | -------------------------------------------------------------------------------- /lib/decorators/api-hide-property.decorator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | export function ApiHideProperty(): PropertyDecorator { 3 | return (target: Record, propertyKey: string | symbol) => {}; 4 | } 5 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | export * from './decorators'; 3 | export * from './document-builder'; 4 | export * from './interfaces'; 5 | export * from './swagger-module'; 6 | export * from './type-helpers'; 7 | export * from './utils'; 8 | -------------------------------------------------------------------------------- /lib/decorators/api-use-tags.decorator.ts: -------------------------------------------------------------------------------- 1 | import { DECORATORS } from '../constants'; 2 | import { createMixedDecorator } from './helpers'; 3 | 4 | export function ApiTags(...tags: string[]) { 5 | return createMixedDecorator(DECORATORS.API_TAGS, tags); 6 | } 7 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "test", 4 | "testEnvironment": "node", 5 | "testRegex": ".spec.ts$", 6 | "verbose": true, 7 | "transform": { 8 | "^.+\\.(t|j)s$": "ts-jest" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /e2e/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "verbose": true, 7 | "transform": { 8 | "^.+\\.(t|j)s$": "ts-jest" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/decorators/api-consumes.decorator.ts: -------------------------------------------------------------------------------- 1 | import { DECORATORS } from '../constants'; 2 | import { createMixedDecorator } from './helpers'; 3 | 4 | export function ApiConsumes(...mimeTypes: string[]) { 5 | return createMixedDecorator(DECORATORS.API_CONSUMES, mimeTypes); 6 | } 7 | -------------------------------------------------------------------------------- /lib/decorators/api-produces.decorator.ts: -------------------------------------------------------------------------------- 1 | import { DECORATORS } from '../constants'; 2 | import { createMixedDecorator } from './helpers'; 3 | 4 | export function ApiProduces(...mimeTypes: string[]) { 5 | return createMixedDecorator(DECORATORS.API_PRODUCES, mimeTypes); 6 | } 7 | -------------------------------------------------------------------------------- /lib/decorators/api-extra-models.decorator.ts: -------------------------------------------------------------------------------- 1 | import { DECORATORS } from '../constants'; 2 | import { createMixedDecorator } from './helpers'; 3 | 4 | export function ApiExtraModels(...models: Function[]) { 5 | return createMixedDecorator(DECORATORS.API_EXTRA_MODELS, models); 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # IDE 5 | /.idea 6 | /.awcache 7 | /.vscode 8 | .history 9 | 10 | # misc 11 | npm-debug.log 12 | .DS_Store 13 | 14 | # tests 15 | /coverage 16 | /.nyc_output 17 | 18 | # source 19 | dist 20 | index.js 21 | index.d.ts 22 | /sample -------------------------------------------------------------------------------- /e2e/src/cats/cats.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CatsController } from './cats.controller'; 3 | import { CatsService } from './cats.service'; 4 | 5 | @Module({ 6 | controllers: [CatsController], 7 | providers: [CatsService] 8 | }) 9 | export class CatsModule {} 10 | -------------------------------------------------------------------------------- /lib/explorers/api-operation.explorer.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { DECORATORS } from '../constants'; 3 | 4 | export const exploreApiOperationMetadata = ( 5 | instance: object, 6 | prototype: Type, 7 | method: object 8 | ) => Reflect.getMetadata(DECORATORS.API_OPERATION, method); 9 | -------------------------------------------------------------------------------- /lib/decorators/api-exclude-endpoint.decorator.ts: -------------------------------------------------------------------------------- 1 | import { DECORATORS } from '../constants'; 2 | import { createMethodDecorator } from './helpers'; 3 | 4 | export function ApiExcludeEndpoint(disable = true): MethodDecorator { 5 | return createMethodDecorator(DECORATORS.API_EXCLUDE_ENDPOINT, { 6 | disable 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /lib/explorers/api-exclude-endpoint.explorer.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { DECORATORS } from '../constants'; 3 | 4 | export const exploreApiExcludeEndpointMetadata = ( 5 | instance: object, 6 | prototype: Type, 7 | method: object 8 | ) => Reflect.getMetadata(DECORATORS.API_EXCLUDE_ENDPOINT, method); 9 | -------------------------------------------------------------------------------- /lib/explorers/api-headers.explorer.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { DECORATORS } from '../constants'; 3 | 4 | export const exploreGlobalApiHeaderMetadata = (metatype: Type) => { 5 | const headers = Reflect.getMetadata(DECORATORS.API_HEADERS, metatype); 6 | return headers ? { root: { parameters: headers }, depth: 1 } : undefined; 7 | }; 8 | -------------------------------------------------------------------------------- /lib/fixtures/document.base.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIObject } from '../interfaces'; 2 | 3 | export const buildDocumentBase = (): Omit => ({ 4 | openapi: '3.0.0', 5 | info: { 6 | title: '', 7 | description: '', 8 | version: '1.0.0', 9 | contact: {} 10 | }, 11 | tags: [], 12 | servers: [], 13 | components: {} 14 | }); 15 | -------------------------------------------------------------------------------- /lib/utils/is-built-in-type.util.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { isFunction } from 'lodash'; 3 | import { BUILT_IN_TYPES } from '../services/constants'; 4 | 5 | export function isBuiltInType( 6 | type: Type | Function | string 7 | ): boolean { 8 | return isFunction(type) && BUILT_IN_TYPES.some((item) => item === type); 9 | } 10 | -------------------------------------------------------------------------------- /lib/utils/reverse-object-keys.util.ts: -------------------------------------------------------------------------------- 1 | export function reverseObjectKeys( 2 | originalObject: Record 3 | ): Record { 4 | const reversedObject = {}; 5 | const keys = Object.keys(originalObject).reverse(); 6 | for (const key of keys) { 7 | reversedObject[key] = originalObject[key]; 8 | } 9 | return reversedObject; 10 | } 11 | -------------------------------------------------------------------------------- /lib/interfaces/denormalized-doc.interface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OpenAPIObject, 3 | OperationObject, 4 | ResponsesObject 5 | } from './open-api-spec.interface'; 6 | 7 | export interface DenormalizedDoc extends Partial { 8 | root?: { 9 | method: string; 10 | path: string; 11 | } & OperationObject; 12 | responses?: ResponsesObject; 13 | } 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # source 2 | lib 3 | sample 4 | test 5 | e2e 6 | 7 | jest.config.json 8 | index.ts 9 | plugin.ts 10 | package-lock.json 11 | .eslintignore 12 | .eslintrc.js 13 | tsconfig.json 14 | .prettierrc 15 | .circleci 16 | .github 17 | 18 | # misc 19 | renovate.json 20 | npm-debug.log 21 | .DS_Store 22 | .commitlintrc.json 23 | .release-it.json 24 | CONTRIBUTING.md 25 | -------------------------------------------------------------------------------- /lib/utils/extend-metadata.util.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | export function extendMetadata[] = any[]>( 4 | metadata: T, 5 | metakey: string, 6 | target: object 7 | ) { 8 | const existingMetadata = Reflect.getMetadata(metakey, target); 9 | if (!existingMetadata) { 10 | return metadata; 11 | } 12 | return existingMetadata.concat(metadata); 13 | } 14 | -------------------------------------------------------------------------------- /test/type-helpers/type-helpers.test-utils.ts: -------------------------------------------------------------------------------- 1 | import { getFromContainer, MetadataStorage } from 'class-validator'; 2 | 3 | export function getValidationMetadataByTarget(target: Function) { 4 | const metadataStorage = getFromContainer(MetadataStorage); 5 | const targetMetadata = metadataStorage.getTargetValidationMetadatas( 6 | target, 7 | null 8 | ); 9 | return targetMetadata; 10 | } 11 | -------------------------------------------------------------------------------- /test/services/fixtures/create-profile.dto.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { ApiProperty } from '../../../lib/decorators'; 3 | import { CreateUserDto } from './create-user.dto'; 4 | 5 | export class CreateProfileDto { 6 | @ApiProperty() 7 | firstname: string; 8 | 9 | @ApiProperty() 10 | lastname: string; 11 | 12 | @ApiProperty({ 13 | type: () => CreateUserDto 14 | }) 15 | parent: CreateUserDto; 16 | } 17 | -------------------------------------------------------------------------------- /lib/interfaces/swagger-custom-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface SwaggerCustomOptions { 2 | explorer?: boolean; 3 | swaggerOptions?: Record; 4 | customCss?: string; 5 | customCssUrl?: string; 6 | customJs?: string; 7 | customfavIcon?: string; 8 | swaggerUrl?: string; 9 | customSiteTitle?: string; 10 | validatorUrl?: string; 11 | url?: string; 12 | urls?: Record<'url' | 'name', string>[]; 13 | } 14 | -------------------------------------------------------------------------------- /lib/interfaces/schema-object-metadata.interface.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { SchemaObject } from './open-api-spec.interface'; 3 | 4 | export interface SchemaObjectMetadata 5 | extends Omit { 6 | type?: Type | Function | [Function] | string | Record; 7 | isArray?: boolean; 8 | required?: boolean; 9 | name?: string; 10 | enumName?: string; 11 | } 12 | -------------------------------------------------------------------------------- /lib/utils/get-schema-path.util.ts: -------------------------------------------------------------------------------- 1 | import { isString } from '@nestjs/common/utils/shared.utils'; 2 | 3 | export function getSchemaPath(model: string | Function): string { 4 | const modelName = isString(model) ? model : model && model.name; 5 | return `#/components/schemas/${modelName}`; 6 | } 7 | 8 | export function refs(...models: Function[]) { 9 | return models.map((item) => ({ 10 | $ref: getSchemaPath(item.name) 11 | })); 12 | } 13 | -------------------------------------------------------------------------------- /e2e/src/cats/cats.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Cat } from './classes/cat.class'; 3 | import { CreateCatDto } from './dto/create-cat.dto'; 4 | 5 | @Injectable() 6 | export class CatsService { 7 | private readonly cats: Cat[] = []; 8 | 9 | create(cat: CreateCatDto): Cat { 10 | this.cats.push(cat); 11 | return cat; 12 | } 13 | 14 | findOne(id: number): Cat { 15 | return this.cats[id]; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/services/mimetype-content-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { ContentObject } from '../interfaces/open-api-spec.interface'; 2 | 3 | export class MimetypeContentWrapper { 4 | wrap( 5 | mimetype: string[], 6 | obj: Record 7 | ): Record<'content', ContentObject> { 8 | const content = mimetype 9 | .map((item) => ({ 10 | [item]: obj 11 | })) 12 | .reduce((a, b) => ({ ...a, ...b }), {}); 13 | return { content }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/explorers/api-use-tags.explorer.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { DECORATORS } from '../constants'; 3 | 4 | export const exploreGlobalApiTagsMetadata = (metatype: Type) => { 5 | const tags = Reflect.getMetadata(DECORATORS.API_TAGS, metatype); 6 | return tags ? { tags } : undefined; 7 | }; 8 | 9 | export const exploreApiTagsMetadata = ( 10 | instance: object, 11 | prototype: Type, 12 | method: object 13 | ) => Reflect.getMetadata(DECORATORS.API_TAGS, method); 14 | -------------------------------------------------------------------------------- /lib/explorers/api-produces.explorer.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { DECORATORS } from '../constants'; 3 | 4 | export const exploreGlobalApiProducesMetadata = (metatype: Type) => { 5 | const produces = Reflect.getMetadata(DECORATORS.API_PRODUCES, metatype); 6 | return produces ? { produces } : undefined; 7 | }; 8 | 9 | export const exploreApiProducesMetadata = ( 10 | instance: object, 11 | prototype: Type, 12 | method: object 13 | ) => Reflect.getMetadata(DECORATORS.API_PRODUCES, method); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "noImplicitAny": false, 6 | "removeComments": true, 7 | "noLib": false, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "target": "es6", 11 | "sourceMap": false, 12 | "outDir": "./dist", 13 | "rootDir": "./lib", 14 | "skipLibCheck": true 15 | }, 16 | "include": [ 17 | "lib/**/*" 18 | ], 19 | "exclude": [ 20 | "node_modules", 21 | "**/*.spec.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /lib/explorers/api-consumes.explorer.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { DECORATORS } from '../constants'; 3 | 4 | export const exploreGlobalApiConsumesMetadata = (metatype: Type) => { 5 | const consumes = Reflect.getMetadata(DECORATORS.API_CONSUMES, metatype); 6 | return consumes ? { consumes } : undefined; 7 | }; 8 | 9 | export const exploreApiConsumesMetadata = ( 10 | instance: object, 11 | prototype: Type, 12 | method: object 13 | ): string[] | undefined => Reflect.getMetadata(DECORATORS.API_CONSUMES, method); 14 | -------------------------------------------------------------------------------- /lib/explorers/api-security.explorer.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { DECORATORS } from '../constants'; 3 | 4 | export const exploreGlobalApiSecurityMetadata = (metatype: Type) => { 5 | const security = Reflect.getMetadata(DECORATORS.API_SECURITY, metatype); 6 | return security ? { security } : undefined; 7 | }; 8 | 9 | export const exploreApiSecurityMetadata = ( 10 | instance: object, 11 | prototype: Type, 12 | method: object 13 | ) => { 14 | return Reflect.getMetadata(DECORATORS.API_SECURITY, method); 15 | }; 16 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-angular"], 3 | "rules": { 4 | "subject-case": [ 5 | 2, 6 | "always", 7 | ["sentence-case", "start-case", "pascal-case", "upper-case", "lower-case"] 8 | ], 9 | "type-enum": [ 10 | 2, 11 | "always", 12 | [ 13 | "build", 14 | "chore", 15 | "ci", 16 | "docs", 17 | "feat", 18 | "fix", 19 | "perf", 20 | "refactor", 21 | "revert", 22 | "style", 23 | "test", 24 | "sample" 25 | ] 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/explorers/api-extra-models.explorer.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { DECORATORS } from '../constants'; 3 | 4 | export const exploreGlobalApiExtraModelsMetadata = ( 5 | metatype: Type 6 | ): Function[] => { 7 | const extraModels = Reflect.getMetadata( 8 | DECORATORS.API_EXTRA_MODELS, 9 | metatype 10 | ); 11 | return extraModels || []; 12 | }; 13 | 14 | export const exploreApiExtraModelsMetadata = ( 15 | instance: object, 16 | prototype: Type, 17 | method: object 18 | ): Function[] => Reflect.getMetadata(DECORATORS.API_EXTRA_MODELS, method) || []; 19 | -------------------------------------------------------------------------------- /lib/decorators/api-extension.decorator.ts: -------------------------------------------------------------------------------- 1 | import { DECORATORS } from '../constants'; 2 | import { createMixedDecorator } from './helpers'; 3 | 4 | export function ApiExtension(extensionKey: string, extensionProperties: any) { 5 | if (!extensionKey.startsWith('x-')) { 6 | throw new Error( 7 | 'Extension key is not prefixed. Please ensure you prefix it with `x-`.' 8 | ); 9 | } 10 | 11 | const extensionObject = { 12 | [extensionKey]: 13 | typeof extensionProperties !== 'string' 14 | ? { ...extensionProperties } 15 | : extensionProperties 16 | }; 17 | 18 | return createMixedDecorator(DECORATORS.API_EXTENSION, extensionObject); 19 | } 20 | -------------------------------------------------------------------------------- /test/plugin/fixtures/nullable.dto.ts: -------------------------------------------------------------------------------- 1 | export const nullableDtoText = ` 2 | export class NullableDto { 3 | @ApiProperty() 4 | stringValue: string | null; 5 | @ApiProperty() 6 | stringArr: string[] | null; 7 | } 8 | `; 9 | 10 | export const nullableDtoTextTranspiled = `export class NullableDto { 11 | static _OPENAPI_METADATA_FACTORY() { 12 | return { stringValue: { required: true, type: () => String, nullable: true }, stringArr: { required: true, type: () => [String], nullable: true } }; 13 | } 14 | } 15 | __decorate([ 16 | ApiProperty() 17 | ], NullableDto.prototype, "stringValue", void 0); 18 | __decorate([ 19 | ApiProperty() 20 | ], NullableDto.prototype, "stringArr", void 0); 21 | `; 22 | -------------------------------------------------------------------------------- /lib/decorators/api-operation.decorator.ts: -------------------------------------------------------------------------------- 1 | import { isUndefined, negate, pickBy } from 'lodash'; 2 | import { DECORATORS } from '../constants'; 3 | import { OperationObject } from '../interfaces/open-api-spec.interface'; 4 | import { createMethodDecorator } from './helpers'; 5 | 6 | export type ApiOperationOptions = Partial; 7 | 8 | const defaultOperationOptions: ApiOperationOptions = { 9 | summary: '' 10 | }; 11 | 12 | export function ApiOperation(options: ApiOperationOptions): MethodDecorator { 13 | return createMethodDecorator( 14 | DECORATORS.API_OPERATION, 15 | pickBy( 16 | { 17 | ...defaultOperationOptions, 18 | ...options 19 | } as ApiOperationOptions, 20 | negate(isUndefined) 21 | ) 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /test/plugin/fixtures/changed-class.dto.ts: -------------------------------------------------------------------------------- 1 | export const originalCatDtoText = ` 2 | export class ChangedCatDto { 3 | name: string; 4 | status: string; 5 | } 6 | `; 7 | 8 | export const changedCatDtoText = ` 9 | export class ChangedCatDto { 10 | name: string; 11 | } 12 | `; 13 | 14 | export const changedCatDtoTextTranspiled = `\"use strict\"; 15 | Object.defineProperty(exports, \"__esModule\", { value: true }); 16 | exports.ChangedCatDto = void 0; 17 | var openapi = require(\"@nestjs/swagger\"); 18 | var ChangedCatDto = /** @class */ (function () { 19 | function ChangedCatDto() { 20 | } 21 | ChangedCatDto._OPENAPI_METADATA_FACTORY = function () { 22 | return { name: { required: true, type: function () { return String; } } }; 23 | }; 24 | return ChangedCatDto; 25 | }()); 26 | exports.ChangedCatDto = ChangedCatDto; 27 | `; 28 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const DECORATORS_PREFIX = 'swagger'; 2 | export const DECORATORS = { 3 | API_OPERATION: `${DECORATORS_PREFIX}/apiOperation`, 4 | API_RESPONSE: `${DECORATORS_PREFIX}/apiResponse`, 5 | API_PRODUCES: `${DECORATORS_PREFIX}/apiProduces`, 6 | API_CONSUMES: `${DECORATORS_PREFIX}/apiConsumes`, 7 | API_TAGS: `${DECORATORS_PREFIX}/apiUseTags`, 8 | API_PARAMETERS: `${DECORATORS_PREFIX}/apiParameters`, 9 | API_HEADERS: `${DECORATORS_PREFIX}/apiHeaders`, 10 | API_MODEL_PROPERTIES: `${DECORATORS_PREFIX}/apiModelProperties`, 11 | API_MODEL_PROPERTIES_ARRAY: `${DECORATORS_PREFIX}/apiModelPropertiesArray`, 12 | API_SECURITY: `${DECORATORS_PREFIX}/apiSecurity`, 13 | API_EXCLUDE_ENDPOINT: `${DECORATORS_PREFIX}/apiExcludeEndpoint`, 14 | API_EXTRA_MODELS: `${DECORATORS_PREFIX}/apiExtraModels`, 15 | API_EXTENSION: `${DECORATORS_PREFIX}/apiExtension` 16 | }; 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module' 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint' 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | '@typescript-eslint/no-use-before-define': 'off', 24 | '@typescript-eslint/no-unused-vars': 'off', 25 | '@typescript-eslint/explicit-module-boundary-types': 'off', 26 | '@typescript-eslint/ban-types': 'off' 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /test/services/model-properties-accessor.spec.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { ApiProperty } from '../../lib/decorators'; 3 | import { ModelPropertiesAccessor } from '../../lib/services/model-properties-accessor'; 4 | 5 | describe('ModelPropertiesAccessor', () => { 6 | class CreateUserDto { 7 | @ApiProperty() 8 | login: string; 9 | 10 | @ApiProperty() 11 | password: string; 12 | } 13 | 14 | let modelPropertiesAccessor: ModelPropertiesAccessor; 15 | 16 | beforeEach(() => { 17 | modelPropertiesAccessor = new ModelPropertiesAccessor(); 18 | }); 19 | 20 | describe('getModelProperties', () => { 21 | it('should return all decorated properties', () => { 22 | expect( 23 | modelPropertiesAccessor.getModelProperties( 24 | (CreateUserDto.prototype as any) as Type 25 | ) 26 | ).toEqual(['login', 'password']); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /lib/interfaces/swagger-document-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface SwaggerDocumentOptions { 2 | /** 3 | * List of modules to include in the specification 4 | */ 5 | include?: Function[]; 6 | 7 | /** 8 | * Additional, extra models that should be inspected and included in the specification 9 | */ 10 | extraModels?: Function[]; 11 | 12 | /** 13 | * If `true`, swagger will ignore the global prefix set through `setGlobalPrefix()` method 14 | */ 15 | ignoreGlobalPrefix?: boolean; 16 | 17 | /** 18 | * If `true`, swagger will also load routes from the modules imported by `include` modules 19 | */ 20 | deepScanRoutes?: boolean; 21 | 22 | /** 23 | * Custom operationIdFactory that will be used to generate the `operationId` 24 | * based on the `controllerKey` and `methodKey` 25 | * @default () => controllerKey_methodKey 26 | */ 27 | operationIdFactory?: (controllerKey: string, methodKey: string) => string; 28 | } 29 | -------------------------------------------------------------------------------- /lib/swagger-transformer.ts: -------------------------------------------------------------------------------- 1 | import { filter, groupBy, keyBy, mapValues, omit } from 'lodash'; 2 | import { OpenAPIObject } from './interfaces'; 3 | 4 | export class SwaggerTransformer { 5 | public normalizePaths( 6 | denormalizedDoc: (Partial & Record<'root', any>)[] 7 | ): Record<'paths', OpenAPIObject['paths']> { 8 | const roots = filter(denormalizedDoc, (r) => r.root); 9 | const groupedByPath = groupBy( 10 | roots, 11 | ({ root }: Record<'root', any>) => root.path 12 | ); 13 | const paths = mapValues(groupedByPath, (routes) => { 14 | const keyByMethod = keyBy( 15 | routes, 16 | ({ root }: Record<'root', any>) => root.method 17 | ); 18 | return mapValues(keyByMethod, (route: any) => { 19 | return { 20 | ...omit(route.root, ['method', 'path']), 21 | ...omit(route, 'root') 22 | }; 23 | }); 24 | }); 25 | return { 26 | paths 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api-basic.decorator'; 2 | export * from './api-bearer.decorator'; 3 | export * from './api-body.decorator'; 4 | export * from './api-consumes.decorator'; 5 | export * from './api-cookie.decorator'; 6 | export * from './api-exclude-endpoint.decorator'; 7 | export * from './api-extra-models.decorator'; 8 | export * from './api-header.decorator'; 9 | export * from './api-hide-property.decorator'; 10 | export * from './api-oauth2.decorator'; 11 | export * from './api-operation.decorator'; 12 | export * from './api-param.decorator'; 13 | export * from './api-produces.decorator'; 14 | export { 15 | ApiProperty, 16 | ApiPropertyOptional, 17 | ApiPropertyOptions, 18 | ApiResponseProperty 19 | } from './api-property.decorator'; 20 | export * from './api-query.decorator'; 21 | export * from './api-response.decorator'; 22 | export * from './api-security.decorator'; 23 | export * from './api-use-tags.decorator'; 24 | export * from './api-extension.decorator'; 25 | -------------------------------------------------------------------------------- /lib/decorators/api-security.decorator.ts: -------------------------------------------------------------------------------- 1 | import { DECORATORS } from '../constants'; 2 | import { SecurityRequirementObject } from '../interfaces/open-api-spec.interface'; 3 | import { extendMetadata } from '../utils/extend-metadata.util'; 4 | 5 | export function ApiSecurity( 6 | name: string, 7 | requirements: string[] = [] 8 | ): ClassDecorator & MethodDecorator { 9 | let metadata: SecurityRequirementObject[] = [{ [name]: requirements }]; 10 | 11 | return ( 12 | target: object, 13 | key?: string | symbol, 14 | descriptor?: TypedPropertyDescriptor 15 | ): any => { 16 | if (descriptor) { 17 | metadata = extendMetadata( 18 | metadata, 19 | DECORATORS.API_SECURITY, 20 | descriptor.value 21 | ); 22 | Reflect.defineMetadata( 23 | DECORATORS.API_SECURITY, 24 | metadata, 25 | descriptor.value 26 | ); 27 | return descriptor; 28 | } 29 | metadata = extendMetadata(metadata, DECORATORS.API_SECURITY, target); 30 | Reflect.defineMetadata(DECORATORS.API_SECURITY, metadata, target); 31 | return target; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /lib/plugin/merge-options.ts: -------------------------------------------------------------------------------- 1 | import { isString } from '@nestjs/common/utils/shared.utils'; 2 | 3 | export interface PluginOptions { 4 | dtoFileNameSuffix?: string | string[]; 5 | controllerFileNameSuffix?: string | string[]; 6 | classValidatorShim?: boolean; 7 | dtoKeyOfComment?: string; 8 | controllerKeyOfComment?: string; 9 | introspectComments?: boolean; 10 | } 11 | 12 | const defaultOptions: PluginOptions = { 13 | dtoFileNameSuffix: ['.dto.ts', '.entity.ts'], 14 | controllerFileNameSuffix: ['.controller.ts'], 15 | classValidatorShim: true, 16 | dtoKeyOfComment: 'description', 17 | controllerKeyOfComment: 'description', 18 | introspectComments: false 19 | }; 20 | 21 | export const mergePluginOptions = ( 22 | options: Record = {} 23 | ): PluginOptions => { 24 | if (isString(options.dtoFileNameSuffix)) { 25 | options.dtoFileNameSuffix = [options.dtoFileNameSuffix]; 26 | } 27 | if (isString(options.controllerFileNameSuffix)) { 28 | options.controllerFileNameSuffix = [options.controllerFileNameSuffix]; 29 | } 30 | return { 31 | ...defaultOptions, 32 | ...options 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kamil Mysliwiec 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/plugin/compiler-plugin.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { mergePluginOptions } from './merge-options'; 3 | import { ControllerClassVisitor } from './visitors/controller-class.visitor'; 4 | import { ModelClassVisitor } from './visitors/model-class.visitor'; 5 | 6 | const modelClassVisitor = new ModelClassVisitor(); 7 | const controllerClassVisitor = new ControllerClassVisitor(); 8 | const isFilenameMatched = (patterns: string[], filename: string) => 9 | patterns.some((path) => filename.includes(path)); 10 | 11 | export const before = (options?: Record, program?: ts.Program) => { 12 | options = mergePluginOptions(options); 13 | 14 | return (ctx: ts.TransformationContext): ts.Transformer => { 15 | return (sf: ts.SourceFile) => { 16 | if (isFilenameMatched(options.dtoFileNameSuffix, sf.fileName)) { 17 | return modelClassVisitor.visit(sf, ctx, program, options); 18 | } 19 | if (isFilenameMatched(options.controllerFileNameSuffix, sf.fileName)) { 20 | return controllerClassVisitor.visit(sf, ctx, program, options); 21 | } 22 | return sf; 23 | }; 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /test/plugin/controller-class-visitor.spec.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { before } from '../../lib/plugin/compiler-plugin'; 3 | import { 4 | appControllerText, 5 | appControllerTextTranspiled 6 | } from './fixtures/app.controller'; 7 | 8 | describe('Controller methods', () => { 9 | it('should add response based on the return value', () => { 10 | const options: ts.CompilerOptions = { 11 | module: ts.ModuleKind.CommonJS, 12 | target: ts.ScriptTarget.ESNext, 13 | newLine: ts.NewLineKind.LineFeed, 14 | noEmitHelpers: true 15 | }; 16 | const filename = 'app.controller.ts'; 17 | const fakeProgram = ts.createProgram([filename], options); 18 | 19 | const result = ts.transpileModule(appControllerText, { 20 | compilerOptions: options, 21 | fileName: filename, 22 | transformers: { 23 | before: [ 24 | before( 25 | { controllerKeyOfComment: 'summary', introspectComments: true }, 26 | fakeProgram 27 | ) 28 | ] 29 | } 30 | }); 31 | expect(result.outputText).toEqual(appControllerTextTranspiled); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /e2e/src/cats/dto/pagination-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '../../../../lib'; 2 | 3 | export enum LettersEnum { 4 | A = 'A', 5 | B = 'B', 6 | C = 'C' 7 | } 8 | 9 | export class PaginationQuery { 10 | @ApiProperty({ 11 | minimum: 0, 12 | maximum: 10000, 13 | title: 'Page', 14 | exclusiveMaximum: true, 15 | exclusiveMinimum: true, 16 | format: 'int32', 17 | default: 0 18 | }) 19 | page: number; 20 | 21 | @ApiProperty({ 22 | name: '_sortBy' 23 | }) 24 | sortBy: string[]; 25 | 26 | @ApiProperty() 27 | limit: number; 28 | 29 | @ApiProperty({ 30 | enum: LettersEnum, 31 | enumName: 'LettersEnum' 32 | }) 33 | enum: LettersEnum; 34 | 35 | @ApiProperty({ 36 | enum: LettersEnum, 37 | enumName: 'LettersEnum', 38 | isArray: true 39 | }) 40 | enumArr: LettersEnum; 41 | 42 | @ApiProperty() 43 | beforeDate: Date; 44 | 45 | @ApiProperty({ 46 | type: 'object', 47 | additionalProperties: true 48 | }) 49 | filter: Record; 50 | 51 | static _OPENAPI_METADATA_FACTORY() { 52 | return { 53 | sortBy: { type: () => [String] } 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before a closed issue or pull request is locked 2 | daysUntilLock: 90 3 | 4 | # Skip issues and pull requests created before a given timestamp. Timestamp must 5 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 6 | skipCreatedBefore: false 7 | 8 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 9 | exemptLabels: [] 10 | 11 | # Label to add before locking, such as `outdated`. Set to `false` to disable 12 | lockLabel: false 13 | 14 | # Comment to post before locking. Set to `false` to disable 15 | lockComment: > 16 | This thread has been automatically locked since there has not been 17 | any recent activity after it was closed. Please open a new issue for 18 | related bugs. 19 | 20 | # Assign `resolved` as the reason for locking. Set to `false` to disable 21 | setLockReason: true 22 | 23 | # Limit to only `issues` or `pulls` 24 | # only: issues 25 | 26 | # Optionally, specify configuration settings just for `issues` or `pulls` 27 | # issues: 28 | # exemptLabels: 29 | # - help-wanted 30 | # lockLabel: outdated 31 | 32 | # pulls: 33 | # daysUntilLock: 30 34 | 35 | # Repository to extend settings from 36 | # _extends: repo 37 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | Please check if your PR fulfills the following requirements: 3 | 4 | - [ ] The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md 5 | - [ ] Tests for the changes have been added (for bug fixes / features) 6 | - [ ] Docs have been added / updated (for bug fixes / features) 7 | 8 | 9 | ## PR Type 10 | What kind of change does this PR introduce? 11 | 12 | 13 | ``` 14 | [ ] Bugfix 15 | [ ] Feature 16 | [ ] Code style update (formatting, local variables) 17 | [ ] Refactoring (no functional changes, no api changes) 18 | [ ] Build related changes 19 | [ ] CI related changes 20 | [ ] Other... Please describe: 21 | ``` 22 | 23 | ## What is the current behavior? 24 | 25 | 26 | Issue Number: N/A 27 | 28 | 29 | ## What is the new behavior? 30 | 31 | 32 | ## Does this PR introduce a breaking change? 33 | ``` 34 | [ ] Yes 35 | [ ] No 36 | ``` 37 | 38 | 39 | 40 | 41 | ## Other information -------------------------------------------------------------------------------- /e2e/src/cats/classes/cat.class.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '../../../../lib'; 2 | import { LettersEnum } from '../dto/pagination-query.dto'; 3 | 4 | export class Cat { 5 | @ApiProperty({ example: 'Kitty', description: 'The name of the Cat' }) 6 | name: string; 7 | 8 | @ApiProperty({ example: 1, minimum: 0, description: 'The age of the Cat' }) 9 | age: number; 10 | 11 | @ApiProperty({ 12 | example: 'Maine Coon', 13 | description: 'The breed of the Cat' 14 | }) 15 | breed: string; 16 | 17 | @ApiProperty({ 18 | name: '_tags', 19 | type: [String] 20 | }) 21 | tags?: string[]; 22 | 23 | @ApiProperty() 24 | createdAt: Date; 25 | 26 | @ApiProperty({ 27 | type: String, 28 | isArray: true 29 | }) 30 | urls?: string[]; 31 | 32 | @ApiProperty({ 33 | name: '_options', 34 | type: 'array', 35 | items: { 36 | type: 'object', 37 | properties: { 38 | isReadonly: { 39 | type: 'string' 40 | } 41 | } 42 | } 43 | }) 44 | options?: Record[]; 45 | 46 | @ApiProperty({ 47 | enum: LettersEnum 48 | }) 49 | enum: LettersEnum; 50 | 51 | @ApiProperty({ 52 | enum: LettersEnum, 53 | isArray: true 54 | }) 55 | enumArr: LettersEnum; 56 | } 57 | -------------------------------------------------------------------------------- /lib/type-helpers/mapped-types.utils.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { identity } from 'lodash'; 3 | import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants'; 4 | 5 | export function clonePluginMetadataFactory( 6 | target: Type, 7 | parent: Type, 8 | transformFn: (metadata: Record) => Record = identity 9 | ) { 10 | let targetMetadata = {}; 11 | 12 | do { 13 | if (!parent.constructor) { 14 | return; 15 | } 16 | if (!parent.constructor[METADATA_FACTORY_NAME]) { 17 | continue; 18 | } 19 | const parentMetadata = parent.constructor[METADATA_FACTORY_NAME](); 20 | targetMetadata = { 21 | ...parentMetadata, 22 | ...targetMetadata 23 | }; 24 | } while ( 25 | (parent = Reflect.getPrototypeOf(parent) as Type) && 26 | parent !== Object.prototype && 27 | parent 28 | ); 29 | targetMetadata = transformFn(targetMetadata); 30 | 31 | if (target[METADATA_FACTORY_NAME]) { 32 | const originalFactory = target[METADATA_FACTORY_NAME]; 33 | target[METADATA_FACTORY_NAME] = () => { 34 | const originalMetadata = originalFactory(); 35 | return { 36 | ...originalMetadata, 37 | ...targetMetadata 38 | }; 39 | }; 40 | } else { 41 | target[METADATA_FACTORY_NAME] = () => targetMetadata; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/services/response-object-mapper.ts: -------------------------------------------------------------------------------- 1 | import { omit } from 'lodash'; 2 | import { ApiResponseSchemaHost } from '../decorators'; 3 | import { getSchemaPath } from '../utils'; 4 | import { MimetypeContentWrapper } from './mimetype-content-wrapper'; 5 | 6 | export class ResponseObjectMapper { 7 | private readonly mimetypeContentWrapper = new MimetypeContentWrapper(); 8 | 9 | toArrayRefObject( 10 | response: Record, 11 | name: string, 12 | produces: string[] 13 | ) { 14 | return { 15 | ...response, 16 | ...this.mimetypeContentWrapper.wrap(produces, { 17 | schema: { 18 | type: 'array', 19 | items: { 20 | $ref: getSchemaPath(name) 21 | } 22 | } 23 | }) 24 | }; 25 | } 26 | 27 | toRefObject(response: Record, name: string, produces: string[]) { 28 | return { 29 | ...response, 30 | ...this.mimetypeContentWrapper.wrap(produces, { 31 | schema: { 32 | $ref: getSchemaPath(name) 33 | } 34 | }) 35 | }; 36 | } 37 | 38 | wrapSchemaWithContent(response: ApiResponseSchemaHost, produces: string[]) { 39 | if (!response.schema) { 40 | return response; 41 | } 42 | const content = this.mimetypeContentWrapper.wrap(produces, { 43 | schema: response.schema 44 | }); 45 | return { 46 | ...omit(response, 'schema'), 47 | ...content 48 | }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /e2e/src/cats/dto/create-cat.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiExtraModels, ApiProperty } from '../../../../lib'; 2 | import { ExtraModel } from './extra-model.dto'; 3 | import { LettersEnum } from './pagination-query.dto'; 4 | import { TagDto } from './tag.dto'; 5 | 6 | @ApiExtraModels(ExtraModel) 7 | export class CreateCatDto { 8 | @ApiProperty() 9 | readonly name: string; 10 | 11 | @ApiProperty({ minimum: 1, maximum: 200 }) 12 | readonly age: number; 13 | 14 | @ApiProperty({ name: '_breed', type: String }) 15 | readonly breed: string; 16 | 17 | @ApiProperty({ 18 | type: [String] 19 | }) 20 | readonly tags?: string[]; 21 | 22 | @ApiProperty() 23 | createdAt: Date; 24 | 25 | @ApiProperty({ 26 | type: 'string', 27 | isArray: true 28 | }) 29 | readonly urls?: string[]; 30 | 31 | @ApiProperty({ 32 | type: 'array', 33 | items: { 34 | type: 'object', 35 | properties: { 36 | isReadonly: { 37 | type: 'string' 38 | } 39 | } 40 | } 41 | }) 42 | readonly options?: Record[]; 43 | 44 | @ApiProperty({ 45 | enum: LettersEnum, 46 | enumName: 'LettersEnum' 47 | }) 48 | readonly enum: LettersEnum; 49 | 50 | @ApiProperty({ 51 | enum: LettersEnum, 52 | enumName: 'LettersEnum', 53 | isArray: true 54 | }) 55 | readonly enumArr: LettersEnum; 56 | 57 | @ApiProperty({ description: 'tag', required: false }) 58 | readonly tag: TagDto; 59 | 60 | nested: { 61 | first: string; 62 | second: number; 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## I'm submitting a... 8 | 11 |

12 | [ ] Regression 
13 | [ ] Bug report
14 | [ ] Feature request
15 | [ ] Documentation issue or request
16 | [ ] Support request => Please do not submit support request here, instead post your question on Stack Overflow.
17 | 
18 | 19 | ## Current behavior 20 | 21 | 22 | 23 | ## Expected behavior 24 | 25 | 26 | 27 | ## Minimal reproduction of the problem with instructions 28 | 29 | 30 | ## What is the motivation / use case for changing the behavior? 31 | 32 | 33 | 34 | ## Environment 35 | 36 |

37 | Nest version: X.Y.Z
38 | 
39 |  
40 | For Tooling issues:
41 | - Node version: XX  
42 | - Platform:  
43 | 
44 | Others:
45 | 
46 | 
47 | -------------------------------------------------------------------------------- /lib/services/model-properties-accessor.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { isFunction, isString } from '@nestjs/common/utils/shared.utils'; 3 | import 'reflect-metadata'; 4 | import { DECORATORS } from '../constants'; 5 | import { createApiPropertyDecorator } from '../decorators/api-property.decorator'; 6 | import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants'; 7 | 8 | export class ModelPropertiesAccessor { 9 | getModelProperties(prototype: Type): string[] { 10 | const properties = 11 | Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES_ARRAY, prototype) || 12 | []; 13 | 14 | return properties 15 | .filter(isString) 16 | .filter( 17 | (key: string) => key.charAt(0) === ':' && !isFunction(prototype[key]) 18 | ) 19 | .map((key: string) => key.slice(1)); 20 | } 21 | 22 | applyMetadataFactory(prototype: Type) { 23 | const classPrototype = prototype; 24 | do { 25 | if (!prototype.constructor) { 26 | return; 27 | } 28 | if (!prototype.constructor[METADATA_FACTORY_NAME]) { 29 | continue; 30 | } 31 | const metadata = prototype.constructor[METADATA_FACTORY_NAME](); 32 | const properties = Object.keys(metadata); 33 | properties.forEach((key) => { 34 | createApiPropertyDecorator(metadata[key], false)(classPrototype, key); 35 | }); 36 | } while ( 37 | (prototype = Reflect.getPrototypeOf(prototype) as Type) && 38 | prototype !== Object.prototype && 39 | prototype 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/decorators/api-body.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { omit } from 'lodash'; 3 | import { 4 | RequestBodyObject, 5 | SchemaObject 6 | } from '../interfaces/open-api-spec.interface'; 7 | import { SwaggerEnumType } from '../types/swagger-enum.type'; 8 | import { 9 | addEnumArraySchema, 10 | addEnumSchema, 11 | isEnumArray, 12 | isEnumDefined 13 | } from '../utils/enum.utils'; 14 | import { createParamDecorator, getTypeIsArrayTuple } from './helpers'; 15 | 16 | type RequestBodyOptions = Omit; 17 | 18 | interface ApiBodyMetadata extends RequestBodyOptions { 19 | type?: Type | Function | [Function] | string; 20 | isArray?: boolean; 21 | enum?: SwaggerEnumType; 22 | } 23 | 24 | interface ApiBodySchemaHost extends RequestBodyOptions { 25 | schema: SchemaObject; 26 | } 27 | 28 | export type ApiBodyOptions = ApiBodyMetadata | ApiBodySchemaHost; 29 | 30 | const defaultBodyMetadata: ApiBodyMetadata = { 31 | type: String, 32 | required: true 33 | }; 34 | 35 | export function ApiBody(options: ApiBodyOptions): MethodDecorator { 36 | const [type, isArray] = getTypeIsArrayTuple( 37 | (options as ApiBodyMetadata).type, 38 | (options as ApiBodyMetadata).isArray 39 | ); 40 | const param: ApiBodyMetadata & Record = { 41 | in: 'body', 42 | ...omit(options, 'enum'), 43 | type, 44 | isArray 45 | }; 46 | 47 | if (isEnumArray(options)) { 48 | addEnumArraySchema(param, options); 49 | } else if (isEnumDefined(options)) { 50 | addEnumSchema(param, options); 51 | } 52 | return createParamDecorator(param, defaultBodyMetadata); 53 | } 54 | -------------------------------------------------------------------------------- /e2e/validate-schema.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { writeFileSync } from 'fs'; 3 | import { join } from 'path'; 4 | import * as SwaggerParser from 'swagger-parser'; 5 | import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '../lib'; 6 | import { ApplicationModule } from './src/app.module'; 7 | 8 | describe('Validate OpenAPI schema', () => { 9 | let document: OpenAPIObject; 10 | 11 | beforeEach(async () => { 12 | const app = await NestFactory.create(ApplicationModule, { 13 | logger: false 14 | }); 15 | app.setGlobalPrefix('api/'); 16 | 17 | const options = new DocumentBuilder() 18 | .setTitle('Cats example') 19 | .setDescription('The cats API description') 20 | .setVersion('1.0') 21 | .setBasePath('api') 22 | .addTag('cats') 23 | .addBasicAuth() 24 | .addBearerAuth() 25 | .addOAuth2() 26 | .addApiKey() 27 | .addCookieAuth() 28 | .addSecurityRequirements('bearer') 29 | .build(); 30 | 31 | document = SwaggerModule.createDocument(app, options); 32 | }); 33 | 34 | it('should produce a valid OpenAPI 3.0 schema', async () => { 35 | const doc = JSON.stringify(document, null, 2); 36 | writeFileSync(join(__dirname, 'api-spec.json'), doc); 37 | 38 | try { 39 | let api = await SwaggerParser.validate(document as any); 40 | console.log( 41 | 'API name: %s, Version: %s', 42 | api.info.title, 43 | api.info.version 44 | ); 45 | expect(api.info.title).toEqual('Cats example'); 46 | } catch (err) { 47 | console.log(doc); 48 | expect(err).toBeUndefined(); 49 | } 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/type-helpers/omit-type.helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { Transform } from 'class-transformer'; 3 | import { MinLength } from 'class-validator'; 4 | import { ApiProperty } from '../../lib/decorators'; 5 | import { METADATA_FACTORY_NAME } from '../../lib/plugin/plugin-constants'; 6 | import { ModelPropertiesAccessor } from '../../lib/services/model-properties-accessor'; 7 | import { OmitType } from '../../lib/type-helpers'; 8 | 9 | describe('OmitType', () => { 10 | class CreateUserDto { 11 | @MinLength(10) 12 | @ApiProperty({ required: true }) 13 | login: string; 14 | 15 | @Transform((str) => str + '_transformed') 16 | @MinLength(10) 17 | @ApiProperty({ minLength: 10 }) 18 | password: string; 19 | 20 | lastName: string; 21 | 22 | static [METADATA_FACTORY_NAME]() { 23 | return { 24 | firstName: { required: true, type: () => String }, 25 | lastName: { required: true, type: () => String } 26 | }; 27 | } 28 | } 29 | 30 | class UpdateUserDto extends OmitType(CreateUserDto, ['login', 'lastName']) {} 31 | 32 | let modelPropertiesAccessor: ModelPropertiesAccessor; 33 | 34 | beforeEach(() => { 35 | modelPropertiesAccessor = new ModelPropertiesAccessor(); 36 | }); 37 | 38 | describe('OpenAPI metadata', () => { 39 | it('should omit "login" property', () => { 40 | const prototype = (UpdateUserDto.prototype as any) as Type; 41 | 42 | modelPropertiesAccessor.applyMetadataFactory(prototype); 43 | expect(modelPropertiesAccessor.getModelProperties(prototype)).toEqual([ 44 | 'password', 45 | 'firstName' 46 | ]); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/type-helpers/pick-type.helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { Transform } from 'class-transformer'; 3 | import { MinLength } from 'class-validator'; 4 | import { ApiProperty } from '../../lib/decorators'; 5 | import { METADATA_FACTORY_NAME } from '../../lib/plugin/plugin-constants'; 6 | import { ModelPropertiesAccessor } from '../../lib/services/model-properties-accessor'; 7 | import { PickType } from '../../lib/type-helpers'; 8 | 9 | describe('PickType', () => { 10 | class CreateUserDto { 11 | @Transform((str) => str + '_transformed') 12 | @MinLength(10) 13 | @ApiProperty({ required: true }) 14 | login: string; 15 | 16 | @MinLength(10) 17 | @ApiProperty({ minLength: 10 }) 18 | password: string; 19 | 20 | firstName: string; 21 | 22 | static [METADATA_FACTORY_NAME]() { 23 | return { 24 | firstName: { required: true, type: () => String }, 25 | lastName: { required: true, type: () => String } 26 | }; 27 | } 28 | } 29 | 30 | class UpdateUserDto extends PickType(CreateUserDto, ['login', 'firstName']) {} 31 | 32 | let modelPropertiesAccessor: ModelPropertiesAccessor; 33 | 34 | beforeEach(() => { 35 | modelPropertiesAccessor = new ModelPropertiesAccessor(); 36 | }); 37 | 38 | describe('OpenAPI metadata', () => { 39 | it('should pick "login" property', () => { 40 | const prototype = (UpdateUserDto.prototype as any) as Type; 41 | 42 | modelPropertiesAccessor.applyMetadataFactory(prototype); 43 | expect(modelPropertiesAccessor.getModelProperties(prototype)).toEqual([ 44 | 'login', 45 | 'firstName' 46 | ]); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /lib/decorators/api-param.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { isNil, omit } from 'lodash'; 3 | import { 4 | ParameterObject, 5 | SchemaObject 6 | } from '../interfaces/open-api-spec.interface'; 7 | import { SwaggerEnumType } from '../types/swagger-enum.type'; 8 | import { getEnumType, getEnumValues } from '../utils/enum.utils'; 9 | import { createParamDecorator } from './helpers'; 10 | 11 | type ParameterOptions = Omit; 12 | 13 | interface ApiParamMetadata extends ParameterOptions { 14 | type?: Type | Function | [Function] | string; 15 | enum?: SwaggerEnumType; 16 | enumName?: string; 17 | } 18 | 19 | interface ApiParamSchemaHost extends ParameterOptions { 20 | schema: SchemaObject; 21 | } 22 | 23 | export type ApiParamOptions = ApiParamMetadata | ApiParamSchemaHost; 24 | 25 | const defaultParamOptions: ApiParamOptions = { 26 | name: '', 27 | required: true 28 | }; 29 | 30 | export function ApiParam(options: ApiParamOptions): MethodDecorator { 31 | const param: Record = { 32 | name: isNil(options.name) ? defaultParamOptions.name : options.name, 33 | in: 'path', 34 | ...omit(options, 'enum') 35 | }; 36 | 37 | const apiParamMetadata = options as ApiParamMetadata; 38 | if (apiParamMetadata.enum) { 39 | param.schema = param.schema || ({} as SchemaObject); 40 | 41 | const paramSchema = param.schema as SchemaObject; 42 | const enumValues = getEnumValues(apiParamMetadata.enum); 43 | paramSchema.type = getEnumType(enumValues); 44 | paramSchema.enum = enumValues; 45 | 46 | if (apiParamMetadata.enumName) { 47 | param.enumName = apiParamMetadata.enumName; 48 | } 49 | } 50 | 51 | return createParamDecorator(param, defaultParamOptions); 52 | } 53 | -------------------------------------------------------------------------------- /lib/type-helpers/omit-type.helper.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { 3 | inheritPropertyInitializers, 4 | inheritTransformationMetadata, 5 | inheritValidationMetadata 6 | } from '@nestjs/mapped-types'; 7 | import { omit } from 'lodash'; 8 | import { DECORATORS } from '../constants'; 9 | import { ApiProperty } from '../decorators'; 10 | import { ModelPropertiesAccessor } from '../services/model-properties-accessor'; 11 | import { clonePluginMetadataFactory } from './mapped-types.utils'; 12 | 13 | const modelPropertiesAccessor = new ModelPropertiesAccessor(); 14 | 15 | export function OmitType( 16 | classRef: Type, 17 | keys: readonly K[] 18 | ): Type> { 19 | const fields = modelPropertiesAccessor 20 | .getModelProperties(classRef.prototype) 21 | .filter((item) => !keys.includes(item as K)); 22 | 23 | const isInheritedPredicate = (propertyKey: string) => 24 | !keys.includes(propertyKey as K); 25 | abstract class OmitTypeClass { 26 | constructor() { 27 | inheritPropertyInitializers(this, classRef, isInheritedPredicate); 28 | } 29 | } 30 | 31 | inheritValidationMetadata(classRef, OmitTypeClass, isInheritedPredicate); 32 | inheritTransformationMetadata(classRef, OmitTypeClass, isInheritedPredicate); 33 | 34 | clonePluginMetadataFactory( 35 | OmitTypeClass as Type, 36 | classRef.prototype, 37 | (metadata: Record) => omit(metadata, keys) 38 | ); 39 | 40 | fields.forEach((propertyKey) => { 41 | const metadata = Reflect.getMetadata( 42 | DECORATORS.API_MODEL_PROPERTIES, 43 | classRef.prototype, 44 | propertyKey 45 | ); 46 | const decoratorFactory = ApiProperty(metadata); 47 | decoratorFactory(OmitTypeClass.prototype, propertyKey); 48 | }); 49 | return OmitTypeClass as Type>; 50 | } 51 | -------------------------------------------------------------------------------- /lib/decorators/api-query.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { isNil, omit } from 'lodash'; 3 | import { 4 | ParameterObject, 5 | ReferenceObject, 6 | SchemaObject 7 | } from '../interfaces/open-api-spec.interface'; 8 | import { SwaggerEnumType } from '../types/swagger-enum.type'; 9 | import { 10 | addEnumArraySchema, 11 | addEnumSchema, 12 | isEnumArray, 13 | isEnumDefined 14 | } from '../utils/enum.utils'; 15 | import { createParamDecorator, getTypeIsArrayTuple } from './helpers'; 16 | 17 | type ParameterOptions = Omit; 18 | 19 | interface ApiQueryMetadata extends ParameterOptions { 20 | name?: string; 21 | type?: Type | Function | [Function] | string; 22 | isArray?: boolean; 23 | enum?: SwaggerEnumType; 24 | enumName?: string; 25 | } 26 | 27 | interface ApiQuerySchemaHost extends ParameterOptions { 28 | name?: string; 29 | schema: SchemaObject | ReferenceObject; 30 | } 31 | 32 | export type ApiQueryOptions = ApiQueryMetadata | ApiQuerySchemaHost; 33 | 34 | const defaultQueryOptions: ApiQueryOptions = { 35 | name: '', 36 | required: true 37 | }; 38 | 39 | export function ApiQuery(options: ApiQueryOptions): MethodDecorator { 40 | const apiQueryMetadata = options as ApiQueryMetadata; 41 | const [type, isArray] = getTypeIsArrayTuple( 42 | apiQueryMetadata.type, 43 | apiQueryMetadata.isArray 44 | ); 45 | const param: ApiQueryMetadata & Record = { 46 | name: isNil(options.name) ? defaultQueryOptions.name : options.name, 47 | in: 'query', 48 | ...omit(options, 'enum'), 49 | type, 50 | isArray 51 | }; 52 | 53 | if (isEnumArray(options)) { 54 | addEnumArraySchema(param, options); 55 | } else if (isEnumDefined(options)) { 56 | addEnumSchema(param, options); 57 | } 58 | 59 | return createParamDecorator(param, defaultQueryOptions); 60 | } 61 | -------------------------------------------------------------------------------- /lib/type-helpers/pick-type.helper.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { 3 | inheritPropertyInitializers, 4 | inheritTransformationMetadata, 5 | inheritValidationMetadata 6 | } from '@nestjs/mapped-types'; 7 | import { pick } from 'lodash'; 8 | import { DECORATORS } from '../constants'; 9 | import { ApiProperty } from '../decorators'; 10 | import { ModelPropertiesAccessor } from '../services/model-properties-accessor'; 11 | import { clonePluginMetadataFactory } from './mapped-types.utils'; 12 | 13 | const modelPropertiesAccessor = new ModelPropertiesAccessor(); 14 | 15 | export function PickType( 16 | classRef: Type, 17 | keys: readonly K[] 18 | ): Type> { 19 | const fields = modelPropertiesAccessor 20 | .getModelProperties(classRef.prototype) 21 | .filter((item) => keys.includes(item as K)); 22 | 23 | const isInheritedPredicate = (propertyKey: string) => 24 | keys.includes(propertyKey as K); 25 | 26 | abstract class PickTypeClass { 27 | constructor() { 28 | inheritPropertyInitializers(this, classRef, isInheritedPredicate); 29 | } 30 | } 31 | 32 | inheritValidationMetadata(classRef, PickTypeClass, isInheritedPredicate); 33 | inheritTransformationMetadata(classRef, PickTypeClass, isInheritedPredicate); 34 | 35 | clonePluginMetadataFactory( 36 | PickTypeClass as Type, 37 | classRef.prototype, 38 | (metadata: Record) => pick(metadata, keys) 39 | ); 40 | 41 | fields.forEach((propertyKey) => { 42 | const metadata = Reflect.getMetadata( 43 | DECORATORS.API_MODEL_PROPERTIES, 44 | classRef.prototype, 45 | propertyKey 46 | ); 47 | const decoratorFactory = ApiProperty(metadata); 48 | decoratorFactory(PickTypeClass.prototype, propertyKey); 49 | }); 50 | 51 | return PickTypeClass as Type>; 52 | } 53 | -------------------------------------------------------------------------------- /test/plugin/fixtures/es5-class.dto.ts: -------------------------------------------------------------------------------- 1 | export const es5CreateCatDtoText = ` 2 | import { IsInt, IsString } from 'class-validator'; 3 | import { Status } from './status'; 4 | import { CONSTANT_STRING, CONSTANT_OBJECT, MIN_VAL } from './constants'; 5 | 6 | export class CreateCatDtoEs5 { 7 | // field name 8 | name: string = CONSTANT_STRING; 9 | /** status */ 10 | status: Status = Status.ENABLED; 11 | obj = CONSTANT_OBJECT; 12 | 13 | @Min(MIN_VAL) 14 | @Max(10) 15 | age: number = 3; 16 | } 17 | `; 18 | 19 | export const es5CreateCatDtoTextTranspiled = `\"use strict\"; 20 | Object.defineProperty(exports, \"__esModule\", { value: true }); 21 | exports.CreateCatDtoEs5 = void 0; 22 | var openapi = require(\"@nestjs/swagger\"); 23 | var status_1 = require(\"./status\"); 24 | var constants_1 = require(\"./constants\"); 25 | var CreateCatDtoEs5 = /** @class */ (function () { 26 | function CreateCatDtoEs5() { 27 | // field name 28 | this.name = constants_1.CONSTANT_STRING; 29 | /** status */ 30 | this.status = status_1.Status.ENABLED; 31 | this.obj = constants_1.CONSTANT_OBJECT; 32 | this.age = 3; 33 | } 34 | CreateCatDtoEs5._OPENAPI_METADATA_FACTORY = function () { 35 | return { name: { required: true, type: function () { return String; }, default: constants_1.CONSTANT_STRING }, status: { required: true, type: function () { return Object; }, description: "status", default: status_1.Status.ENABLED }, obj: { required: true, type: function () { return Object; }, default: constants_1.CONSTANT_OBJECT }, age: { required: true, type: function () { return Number; }, default: 3, minimum: constants_1.MIN_VAL, maximum: 10 } }; 36 | }; 37 | __decorate([ 38 | Min(constants_1.MIN_VAL), 39 | Max(10) 40 | ], CreateCatDtoEs5.prototype, \"age\", void 0); 41 | return CreateCatDtoEs5; 42 | }()); 43 | exports.CreateCatDtoEs5 = CreateCatDtoEs5; 44 | `; 45 | -------------------------------------------------------------------------------- /test/type-helpers/intersection-type.helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { Expose, Transform } from 'class-transformer'; 3 | import { IsString } from 'class-validator'; 4 | import { ApiProperty } from '../../lib/decorators'; 5 | import { METADATA_FACTORY_NAME } from '../../lib/plugin/plugin-constants'; 6 | import { ModelPropertiesAccessor } from '../../lib/services/model-properties-accessor'; 7 | import { IntersectionType } from '../../lib/type-helpers'; 8 | 9 | describe('IntersectionType', () => { 10 | class CreateUserDto { 11 | @ApiProperty({ required: true }) 12 | login: string; 13 | 14 | @Expose() 15 | @Transform((str) => str + '_transformed') 16 | @IsString() 17 | @ApiProperty({ minLength: 10 }) 18 | password: string; 19 | 20 | static [METADATA_FACTORY_NAME]() { 21 | return { dateOfBirth: { required: true, type: () => String } }; 22 | } 23 | } 24 | 25 | class UserDto { 26 | @IsString() 27 | @ApiProperty({ required: false }) 28 | firstName: string; 29 | 30 | static [METADATA_FACTORY_NAME]() { 31 | return { dateOfBirth2: { required: true, type: () => String } }; 32 | } 33 | } 34 | 35 | class UpdateUserDto extends IntersectionType(UserDto, CreateUserDto) {} 36 | 37 | let modelPropertiesAccessor: ModelPropertiesAccessor; 38 | 39 | beforeEach(() => { 40 | modelPropertiesAccessor = new ModelPropertiesAccessor(); 41 | }); 42 | 43 | describe('OpenAPI metadata', () => { 44 | it('should return combined class', () => { 45 | const prototype = (UpdateUserDto.prototype as any) as Type; 46 | 47 | modelPropertiesAccessor.applyMetadataFactory(prototype); 48 | expect(modelPropertiesAccessor.getModelProperties(prototype)).toEqual([ 49 | 'firstName', 50 | 'login', 51 | 'password', 52 | 'dateOfBirth2', 53 | 'dateOfBirth' 54 | ]); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | aliases: 4 | - &restore-cache 5 | restore_cache: 6 | key: dependency-cache-{{ checksum "package.json" }} 7 | - &install-deps 8 | run: 9 | name: Install dependencies 10 | command: npm ci 11 | - &build-packages 12 | run: 13 | name: Build 14 | command: npm run build 15 | - &run-unit-tests 16 | run: 17 | name: Test 18 | command: npm run test 19 | - &run-e2e-tests 20 | run: 21 | name: E2E test 22 | command: npm run test:e2e 23 | 24 | jobs: 25 | build: 26 | working_directory: ~/nest 27 | docker: 28 | - image: circleci/node:12 29 | steps: 30 | - checkout 31 | - run: 32 | name: Update NPM version 33 | command: 'sudo npm install -g npm@latest' 34 | - restore_cache: 35 | key: dependency-cache-{{ checksum "package.json" }} 36 | - run: 37 | name: Install dependencies 38 | command: npm ci 39 | - save_cache: 40 | key: dependency-cache-{{ checksum "package.json" }} 41 | paths: 42 | - ./node_modules 43 | - run: 44 | name: Build 45 | command: npm run build 46 | 47 | unit_tests: 48 | working_directory: ~/nest 49 | docker: 50 | - image: circleci/node:12 51 | steps: 52 | - checkout 53 | - *restore-cache 54 | - *install-deps 55 | - *build-packages 56 | - *run-unit-tests 57 | 58 | e2e_tests: 59 | working_directory: ~/nest 60 | docker: 61 | - image: circleci/node:12 62 | steps: 63 | - checkout 64 | - *restore-cache 65 | - *install-deps 66 | - *build-packages 67 | - *run-e2e-tests 68 | 69 | workflows: 70 | version: 2 71 | build-and-test: 72 | jobs: 73 | - build 74 | - unit_tests: 75 | requires: 76 | - build 77 | - e2e_tests: 78 | requires: 79 | - build 80 | -------------------------------------------------------------------------------- /test/services/fixtures/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { ApiProperty, ApiPropertyOptional } from '../../../lib/decorators'; 3 | import { CreateProfileDto } from './create-profile.dto'; 4 | 5 | class House {} 6 | 7 | export class CreateUserDto { 8 | @ApiProperty() 9 | login: string; 10 | 11 | @ApiProperty({ 12 | examples: ['test', 'test2'] 13 | }) 14 | password: string; 15 | 16 | @ApiPropertyOptional({ 17 | format: 'int64', 18 | example: 10 19 | }) 20 | age?: number; 21 | 22 | @ApiProperty({ 23 | required: false, 24 | readOnly: true, 25 | type: 'array', 26 | maxItems: 10, 27 | minItems: 1, 28 | items: { 29 | type: 'array', 30 | items: { 31 | type: 'number' 32 | } 33 | } 34 | }) 35 | custom: any; 36 | 37 | @ApiProperty({ 38 | description: 'Profile', 39 | nullable: true, 40 | type: () => CreateProfileDto 41 | }) 42 | profile: CreateProfileDto; 43 | 44 | @ApiProperty() 45 | tags: string[]; 46 | 47 | @ApiProperty({ 48 | type: String, 49 | isArray: true 50 | }) 51 | urls: string[]; 52 | 53 | @ApiProperty({ 54 | type: 'integer', 55 | isArray: true 56 | }) 57 | luckyNumbers: number[]; 58 | 59 | @ApiProperty({ 60 | type: 'array', 61 | items: { 62 | type: 'object', 63 | properties: { 64 | isReadonly: { 65 | type: 'string' 66 | } 67 | } 68 | } 69 | }) 70 | options?: Record[]; 71 | 72 | @ApiProperty({ 73 | oneOf: [ 74 | { $ref: '#/components/schemas/Cat' }, 75 | { $ref: '#/components/schemas/Dog' } 76 | ], 77 | discriminator: { propertyName: 'pet_type' } 78 | }) 79 | allOf?: Record; 80 | 81 | @ApiProperty({ type: [House] }) 82 | houses: House[]; 83 | 84 | @ApiProperty() 85 | createdAt: Date; 86 | 87 | static _OPENAPI_METADATA_FACTORY() { 88 | return { 89 | tags: { type: () => [String] } 90 | }; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/type-helpers/partial-type.helper.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { 3 | applyIsOptionalDecorator, 4 | inheritPropertyInitializers, 5 | inheritTransformationMetadata, 6 | inheritValidationMetadata 7 | } from '@nestjs/mapped-types'; 8 | import { mapValues } from 'lodash'; 9 | import { DECORATORS } from '../constants'; 10 | import { ApiProperty } from '../decorators'; 11 | import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants'; 12 | import { ModelPropertiesAccessor } from '../services/model-properties-accessor'; 13 | import { clonePluginMetadataFactory } from './mapped-types.utils'; 14 | 15 | const modelPropertiesAccessor = new ModelPropertiesAccessor(); 16 | 17 | export function PartialType(classRef: Type): Type> { 18 | const fields = modelPropertiesAccessor.getModelProperties(classRef.prototype); 19 | 20 | abstract class PartialTypeClass { 21 | constructor() { 22 | inheritPropertyInitializers(this, classRef); 23 | } 24 | } 25 | inheritValidationMetadata(classRef, PartialTypeClass); 26 | inheritTransformationMetadata(classRef, PartialTypeClass); 27 | 28 | clonePluginMetadataFactory( 29 | PartialTypeClass as Type, 30 | classRef.prototype, 31 | (metadata: Record) => 32 | mapValues(metadata, (item) => ({ ...item, required: false })) 33 | ); 34 | 35 | fields.forEach((key) => { 36 | const metadata = 37 | Reflect.getMetadata( 38 | DECORATORS.API_MODEL_PROPERTIES, 39 | classRef.prototype, 40 | key 41 | ) || {}; 42 | 43 | const decoratorFactory = ApiProperty({ 44 | ...metadata, 45 | required: false 46 | }); 47 | decoratorFactory(PartialTypeClass.prototype, key); 48 | applyIsOptionalDecorator(PartialTypeClass, key); 49 | }); 50 | 51 | if (PartialTypeClass[METADATA_FACTORY_NAME]) { 52 | const pluginFields = Object.keys(PartialTypeClass[METADATA_FACTORY_NAME]()); 53 | pluginFields.forEach((key) => 54 | applyIsOptionalDecorator(PartialTypeClass, key) 55 | ); 56 | } 57 | 58 | return PartialTypeClass as Type>; 59 | } 60 | -------------------------------------------------------------------------------- /lib/decorators/api-property.decorator.ts: -------------------------------------------------------------------------------- 1 | import { DECORATORS } from '../constants'; 2 | import { SchemaObjectMetadata } from '../interfaces/schema-object-metadata.interface'; 3 | import { getEnumType, getEnumValues } from '../utils/enum.utils'; 4 | import { createPropertyDecorator, getTypeIsArrayTuple } from './helpers'; 5 | 6 | export interface ApiPropertyOptions 7 | extends Omit { 8 | name?: string; 9 | enum?: any[] | Record; 10 | enumName?: string; 11 | } 12 | 13 | const isEnumArray = (obj: ApiPropertyOptions): boolean => 14 | obj.isArray && !!obj.enum; 15 | 16 | export function ApiProperty( 17 | options: ApiPropertyOptions = {} 18 | ): PropertyDecorator { 19 | return createApiPropertyDecorator(options); 20 | } 21 | 22 | export function createApiPropertyDecorator( 23 | options: ApiPropertyOptions = {}, 24 | overrideExisting = true 25 | ): PropertyDecorator { 26 | const [type, isArray] = getTypeIsArrayTuple(options.type, options.isArray); 27 | options = { 28 | ...options, 29 | type, 30 | isArray 31 | }; 32 | 33 | if (isEnumArray(options)) { 34 | options.type = 'array'; 35 | 36 | const enumValues = getEnumValues(options.enum); 37 | options.items = { 38 | type: getEnumType(enumValues), 39 | enum: enumValues 40 | }; 41 | delete options.enum; 42 | } else if (options.enum) { 43 | const enumValues = getEnumValues(options.enum); 44 | 45 | options.enum = enumValues; 46 | options.type = getEnumType(enumValues); 47 | } 48 | 49 | return createPropertyDecorator( 50 | DECORATORS.API_MODEL_PROPERTIES, 51 | options, 52 | overrideExisting 53 | ); 54 | } 55 | 56 | export function ApiPropertyOptional( 57 | options: ApiPropertyOptions = {} 58 | ): PropertyDecorator { 59 | return ApiProperty({ 60 | ...options, 61 | required: false 62 | }); 63 | } 64 | 65 | export function ApiResponseProperty( 66 | options: Pick< 67 | ApiPropertyOptions, 68 | 'type' | 'example' | 'format' | 'enum' | 'deprecated' 69 | > = {} 70 | ): PropertyDecorator { 71 | return ApiProperty({ 72 | readOnly: true, 73 | ...options 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /lib/services/parameters-metadata-mapper.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { isFunction } from '@nestjs/common/utils/shared.utils'; 3 | import { flatMap, identity } from 'lodash'; 4 | import { DECORATORS } from '../constants'; 5 | import { isBodyParameter } from '../utils/is-body-parameter.util'; 6 | import { ModelPropertiesAccessor } from './model-properties-accessor'; 7 | import { 8 | ParamsWithType, 9 | ParamWithTypeMetadata 10 | } from './parameter-metadata-accessor'; 11 | 12 | export class ParametersMetadataMapper { 13 | constructor( 14 | private readonly modelPropertiesAccessor: ModelPropertiesAccessor 15 | ) {} 16 | 17 | transformModelToProperties( 18 | parameters: ParamsWithType 19 | ): ParamWithTypeMetadata[] { 20 | const properties = flatMap(parameters, (param: ParamWithTypeMetadata) => { 21 | if (!param || param.type === Object) { 22 | return undefined; 23 | } 24 | if (param.name) { 25 | // when "name" is present, the "data" argument was passed to the decorator 26 | // e.g. `@Query('param') 27 | return param; 28 | } 29 | if (isBodyParameter(param)) { 30 | const isCtor = param.type && isFunction(param.type); 31 | const name = isCtor ? param.type.name : param.type; 32 | return { ...param, name }; 33 | } 34 | const { prototype } = param.type; 35 | 36 | this.modelPropertiesAccessor.applyMetadataFactory(prototype); 37 | const modelProperties = this.modelPropertiesAccessor.getModelProperties( 38 | prototype 39 | ); 40 | 41 | return modelProperties.map((key) => 42 | this.mergeImplicitWithExplicit(key, prototype, param) 43 | ); 44 | }); 45 | return properties.filter(identity); 46 | } 47 | 48 | mergeImplicitWithExplicit( 49 | key: string, 50 | prototype: Type, 51 | param: ParamWithTypeMetadata 52 | ): ParamWithTypeMetadata { 53 | const reflectedParam = 54 | Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES, prototype, key) || 55 | {}; 56 | 57 | return { 58 | ...param, 59 | ...reflectedParam, 60 | name: reflectedParam.name || key 61 | }; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/plugin/visitors/abstract.visitor.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { OPENAPI_NAMESPACE, OPENAPI_PACKAGE_NAME } from '../plugin-constants'; 3 | 4 | export class AbstractFileVisitor { 5 | updateImports( 6 | sourceFile: ts.SourceFile, 7 | factory: ts.NodeFactory | undefined 8 | ): ts.SourceFile { 9 | const [major, minor] = ts.versionMajorMinor?.split('.').map((x) => +x); 10 | if (!factory) { 11 | // support TS v4.2+ 12 | const importEqualsDeclaration = 13 | major == 4 && minor >= 2 14 | ? (ts.createImportEqualsDeclaration as any)( 15 | undefined, 16 | undefined, 17 | false, 18 | OPENAPI_NAMESPACE, 19 | ts.createExternalModuleReference( 20 | ts.createLiteral(OPENAPI_PACKAGE_NAME) 21 | ) 22 | ) 23 | : (ts.createImportEqualsDeclaration as any)( 24 | undefined, 25 | undefined, 26 | OPENAPI_NAMESPACE, 27 | ts.createExternalModuleReference( 28 | ts.createLiteral(OPENAPI_PACKAGE_NAME) 29 | ) 30 | ); 31 | return ts.updateSourceFileNode(sourceFile, [ 32 | importEqualsDeclaration, 33 | ...sourceFile.statements 34 | ]); 35 | } 36 | // support TS v4.2+ 37 | const importEqualsDeclaration = 38 | major == 4 && minor >= 2 39 | ? (factory.createImportEqualsDeclaration as any)( 40 | undefined, 41 | undefined, 42 | false, 43 | OPENAPI_NAMESPACE, 44 | factory.createExternalModuleReference( 45 | factory.createStringLiteral(OPENAPI_PACKAGE_NAME) 46 | ) 47 | ) 48 | : (factory.createImportEqualsDeclaration as any)( 49 | undefined, 50 | undefined, 51 | OPENAPI_NAMESPACE, 52 | factory.createExternalModuleReference( 53 | factory.createStringLiteral(OPENAPI_PACKAGE_NAME) 54 | ) 55 | ); 56 | 57 | return factory.updateSourceFile(sourceFile, [ 58 | importEqualsDeclaration, 59 | ...sourceFile.statements 60 | ]); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/decorators/api-header.decorator.ts: -------------------------------------------------------------------------------- 1 | import { isNil, isUndefined, negate, pickBy } from 'lodash'; 2 | import { DECORATORS } from '../constants'; 3 | import { 4 | ParameterLocation, 5 | ParameterObject 6 | } from '../interfaces/open-api-spec.interface'; 7 | import { SwaggerEnumType } from '../types/swagger-enum.type'; 8 | import { getEnumType, getEnumValues } from '../utils/enum.utils'; 9 | import { createClassDecorator, createParamDecorator } from './helpers'; 10 | 11 | export interface ApiHeaderOptions extends Omit { 12 | enum?: SwaggerEnumType; 13 | } 14 | 15 | const defaultHeaderOptions: Partial = { 16 | name: '' 17 | }; 18 | 19 | export function ApiHeader( 20 | options: ApiHeaderOptions 21 | ): MethodDecorator & ClassDecorator { 22 | const param = pickBy( 23 | { 24 | name: isNil(options.name) ? defaultHeaderOptions.name : options.name, 25 | in: 'header', 26 | description: options.description, 27 | required: options.required, 28 | schema: { 29 | ...(options.schema || {}), 30 | type: 'string' 31 | } 32 | }, 33 | negate(isUndefined) 34 | ); 35 | 36 | if (options.enum) { 37 | const enumValues = getEnumValues(options.enum); 38 | param.schema = { 39 | enum: enumValues, 40 | type: getEnumType(enumValues) 41 | }; 42 | } 43 | 44 | return ( 45 | target: object | Function, 46 | key?: string | symbol, 47 | descriptor?: TypedPropertyDescriptor 48 | ): any => { 49 | if (descriptor) { 50 | return createParamDecorator(param, defaultHeaderOptions)( 51 | target, 52 | key, 53 | descriptor 54 | ); 55 | } 56 | return createClassDecorator(DECORATORS.API_HEADERS, [param])( 57 | target as Function 58 | ); 59 | }; 60 | } 61 | 62 | export const ApiHeaders = ( 63 | headers: ApiHeaderOptions[] 64 | ): MethodDecorator & ClassDecorator => { 65 | return ( 66 | target: object | Function, 67 | key?: string | symbol, 68 | descriptor?: TypedPropertyDescriptor 69 | ): any => { 70 | headers.forEach((options) => ApiHeader(options)(target, key, descriptor)); 71 | }; 72 | }; 73 | -------------------------------------------------------------------------------- /e2e/fastify.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { 3 | FastifyAdapter, 4 | NestFastifyApplication 5 | } from '@nestjs/platform-fastify'; 6 | import * as SwaggerParser from 'swagger-parser'; 7 | import { DocumentBuilder, SwaggerModule } from '../lib'; 8 | import { ApplicationModule } from './src/app.module'; 9 | 10 | describe('Fastify Swagger', () => { 11 | let app: NestFastifyApplication; 12 | let builder: DocumentBuilder; 13 | 14 | beforeEach(async () => { 15 | app = await NestFactory.create( 16 | ApplicationModule, 17 | new FastifyAdapter(), 18 | { logger: false } 19 | ); 20 | 21 | builder = new DocumentBuilder() 22 | .setTitle('Cats example') 23 | .setDescription('The cats API description') 24 | .setVersion('1.0') 25 | .addTag('cats') 26 | .addBasicAuth() 27 | .addBearerAuth() 28 | .addOAuth2() 29 | .addApiKey() 30 | .addCookieAuth() 31 | .addSecurityRequirements('bearer'); 32 | }); 33 | 34 | it('should produce a valid OpenAPI 3.0 schema', async () => { 35 | const document = SwaggerModule.createDocument(app, builder.build()); 36 | const doc = JSON.stringify(document, null, 2); 37 | 38 | try { 39 | let api = await SwaggerParser.validate(document as any); 40 | console.log( 41 | 'API name: %s, Version: %s', 42 | api.info.title, 43 | api.info.version 44 | ); 45 | expect(api.info.title).toEqual('Cats example'); 46 | } catch (err) { 47 | console.log(doc); 48 | expect(err).toBeUndefined(); 49 | } 50 | }); 51 | 52 | it('should setup multiple routes', async () => { 53 | const document1 = SwaggerModule.createDocument(app, builder.build()); 54 | SwaggerModule.setup('/swagger1', app, document1); 55 | 56 | const document2 = SwaggerModule.createDocument(app, builder.build()); 57 | SwaggerModule.setup('/swagger2', app, document2); 58 | 59 | await app.init(); 60 | // otherwise throws "FastifyError [FST_ERR_DEC_ALREADY_PRESENT]: FST_ERR_DEC_ALREADY_PRESENT: The decorator 'swagger' has already been added!" 61 | await expect( 62 | app.getHttpAdapter().getInstance().ready() 63 | ).resolves.toBeDefined(); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /lib/type-helpers/intersection-type.helper.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { 3 | inheritTransformationMetadata, 4 | inheritValidationMetadata, 5 | inheritPropertyInitializers 6 | } from '@nestjs/mapped-types'; 7 | import { DECORATORS } from '../constants'; 8 | import { ApiProperty } from '../decorators'; 9 | import { ModelPropertiesAccessor } from '../services/model-properties-accessor'; 10 | import { clonePluginMetadataFactory } from './mapped-types.utils'; 11 | 12 | const modelPropertiesAccessor = new ModelPropertiesAccessor(); 13 | 14 | export function IntersectionType( 15 | classARef: Type, 16 | classBRef: Type 17 | ): Type { 18 | const fieldsOfA = modelPropertiesAccessor.getModelProperties( 19 | classARef.prototype 20 | ); 21 | const fieldsOfB = modelPropertiesAccessor.getModelProperties( 22 | classBRef.prototype 23 | ); 24 | 25 | abstract class IntersectionTypeClass { 26 | constructor() { 27 | inheritPropertyInitializers(this, classARef); 28 | inheritPropertyInitializers(this, classBRef); 29 | } 30 | } 31 | inheritValidationMetadata(classARef, IntersectionTypeClass); 32 | inheritTransformationMetadata(classARef, IntersectionTypeClass); 33 | inheritValidationMetadata(classBRef, IntersectionTypeClass); 34 | inheritTransformationMetadata(classBRef, IntersectionTypeClass); 35 | 36 | clonePluginMetadataFactory( 37 | IntersectionTypeClass as Type, 38 | classARef.prototype 39 | ); 40 | clonePluginMetadataFactory( 41 | IntersectionTypeClass as Type, 42 | classBRef.prototype 43 | ); 44 | 45 | fieldsOfA.forEach((propertyKey) => { 46 | const metadata = Reflect.getMetadata( 47 | DECORATORS.API_MODEL_PROPERTIES, 48 | classARef.prototype, 49 | propertyKey 50 | ); 51 | const decoratorFactory = ApiProperty(metadata); 52 | decoratorFactory(IntersectionTypeClass.prototype, propertyKey); 53 | }); 54 | 55 | fieldsOfB.forEach((propertyKey) => { 56 | const metadata = Reflect.getMetadata( 57 | DECORATORS.API_MODEL_PROPERTIES, 58 | classBRef.prototype, 59 | propertyKey 60 | ); 61 | const decoratorFactory = ApiProperty(metadata); 62 | decoratorFactory(IntersectionTypeClass.prototype, propertyKey); 63 | }); 64 | 65 | Object.defineProperty(IntersectionTypeClass, 'name', { 66 | value: `Intersection${classARef.name}${classBRef.name}` 67 | }); 68 | return IntersectionTypeClass as Type; 69 | } 70 | -------------------------------------------------------------------------------- /test/plugin/fixtures/create-cat-alt.dto.ts: -------------------------------------------------------------------------------- 1 | export const createCatDtoAltText = ` 2 | import { IsInt, IsString } from 'class-validator'; 3 | import * as package from 'class-validator'; 4 | 5 | enum Status { 6 | ENABLED, 7 | DISABLED 8 | } 9 | 10 | interface Node { 11 | id: number; 12 | } 13 | 14 | type AliasedType = { 15 | type: string; 16 | }; 17 | type NumberAlias = number; 18 | 19 | export class CreateCatDto2 { 20 | @package.IsString() 21 | name: string; 22 | age: number = 3; 23 | tags: string[]; 24 | status: Status = Status.ENABLED; 25 | readonly breed?: string | undefined; 26 | nodes: Node[]; 27 | alias: AliasedType; 28 | /** NumberAlias */ 29 | numberAlias: NumberAlias; 30 | union: 1 | 2; 31 | intersection: Function & string; 32 | nested: { 33 | first: string, 34 | second: number, 35 | status: Status, 36 | tags: string[], 37 | nodes: Node[] 38 | alias: AliasedType, 39 | numberAlias: NumberAlias, 40 | }, 41 | prop: { 42 | [x: string]: string; 43 | } 44 | } 45 | `; 46 | 47 | export const createCatDtoTextAltTranspiled = `import * as package from 'class-validator'; 48 | var Status; 49 | (function (Status) { 50 | Status[Status["ENABLED"] = 0] = "ENABLED"; 51 | Status[Status["DISABLED"] = 1] = "DISABLED"; 52 | })(Status || (Status = {})); 53 | export class CreateCatDto2 { 54 | constructor() { 55 | this.age = 3; 56 | this.status = Status.ENABLED; 57 | } 58 | static _OPENAPI_METADATA_FACTORY() { 59 | return { name: { required: true, type: () => String }, age: { required: true, type: () => Number, default: 3 }, tags: { required: true, type: () => [String] }, status: { required: true, default: Status.ENABLED, enum: Status }, breed: { required: false, type: () => String }, nodes: { required: true, type: () => [Object] }, alias: { required: true, type: () => Object }, numberAlias: { required: true, type: () => Number, description: "NumberAlias" }, union: { required: true, type: () => Object }, intersection: { required: true, type: () => Object }, nested: { required: true, type: () => ({ first: { required: true, type: () => String }, second: { required: true, type: () => Number }, status: { required: true, enum: Status }, tags: { required: true, type: () => [String] }, nodes: { required: true, type: () => [Object] }, alias: { required: true, type: () => Object }, numberAlias: { required: true, type: () => Number } }) } }; 60 | } 61 | } 62 | __decorate([ 63 | package.IsString() 64 | ], CreateCatDto2.prototype, "name", void 0); 65 | `; 66 | -------------------------------------------------------------------------------- /e2e/src/cats/cats.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; 2 | import { 3 | ApiBearerAuth, 4 | ApiConsumes, 5 | ApiExtension, 6 | ApiHeader, 7 | ApiOperation, 8 | ApiQuery, 9 | ApiResponse, 10 | ApiSecurity, 11 | ApiTags 12 | } from '../../../lib'; 13 | import { CatsService } from './cats.service'; 14 | import { Cat } from './classes/cat.class'; 15 | import { CreateCatDto } from './dto/create-cat.dto'; 16 | import { PaginationQuery } from './dto/pagination-query.dto'; 17 | 18 | @ApiSecurity('basic') 19 | @ApiBearerAuth() 20 | @ApiTags('cats') 21 | @ApiHeader({ 22 | name: 'header', 23 | required: false, 24 | description: 'Test', 25 | schema: { default: 'test' } 26 | }) 27 | @Controller('cats') 28 | export class CatsController { 29 | constructor(private readonly catsService: CatsService) {} 30 | 31 | @ApiTags('create cats') 32 | @Post() 33 | @ApiOperation({ summary: 'Create cat' }) 34 | @ApiResponse({ 35 | status: 201, 36 | description: 'The record has been successfully created.', 37 | type: () => Cat 38 | }) 39 | @ApiResponse({ status: 403, description: 'Forbidden.' }) 40 | @ApiExtension('x-foo', { test: 'bar ' }) 41 | async create(@Body() createCatDto: CreateCatDto): Promise { 42 | return this.catsService.create(createCatDto); 43 | } 44 | 45 | @Get(':id') 46 | @ApiResponse({ 47 | status: 200, 48 | description: 'The found record', 49 | type: Cat 50 | }) 51 | @ApiExtension('x-auth-type', 'NONE') 52 | findOne(@Param('id') id: string): Cat { 53 | return this.catsService.findOne(+id); 54 | } 55 | 56 | @Get() 57 | findAll(@Query() paginationQuery: PaginationQuery) {} 58 | 59 | @ApiQuery({ type: PaginationQuery }) 60 | @Get('explicit-query') 61 | findAllWithExplicitQuery(paginationQuery: PaginationQuery) {} 62 | 63 | @Get('bulk') 64 | findAllBulk(@Query() paginationQuery: PaginationQuery[]) {} 65 | 66 | @Post('bulk') 67 | async createBulk(@Body() createCatDto: CreateCatDto[]): Promise { 68 | return null; 69 | } 70 | 71 | @ApiConsumes('application/x-www-form-urlencoded') 72 | @Post('as-form-data') 73 | @ApiOperation({ summary: 'Create cat' }) 74 | @ApiResponse({ 75 | status: 201, 76 | description: 'The record has been successfully created.', 77 | type: Cat 78 | }) 79 | @ApiResponse({ status: 403, description: 'Forbidden.' }) 80 | async createAsFormData(@Body() createCatDto: CreateCatDto): Promise { 81 | return this.catsService.create(createCatDto); 82 | } 83 | 84 | @Get('site*') 85 | getSite() {} 86 | } 87 | -------------------------------------------------------------------------------- /test/plugin/fixtures/app.controller.ts: -------------------------------------------------------------------------------- 1 | export const appControllerText = `import { Controller, Post, HttpStatus } from '@nestjs/common'; 2 | import { ApiOperation } from '@nestjs/swagger'; 3 | 4 | class Cat {} 5 | 6 | @Controller('cats') 7 | export class AppController { 8 | onApplicationBootstrap() {} 9 | 10 | /** 11 | * create a Cat 12 | * 13 | * @returns {Promise} 14 | * @memberof AppController 15 | */ 16 | @Post() 17 | async create(): Promise {} 18 | 19 | /** 20 | * find a Cat 21 | */ 22 | @ApiOperation({}) 23 | @Get() 24 | async findOne(): Promise {} 25 | 26 | /** 27 | * find all Cats im comment 28 | * 29 | * @returns {Promise} 30 | * @memberof AppController 31 | */ 32 | @ApiOperation({ 33 | description: 'find all Cats', 34 | }) 35 | @Get() 36 | @HttpCode(HttpStatus.NO_CONTENT) 37 | async findAll(): Promise {} 38 | }`; 39 | 40 | export const appControllerTextTranspiled = `\"use strict\"; 41 | Object.defineProperty(exports, \"__esModule\", { value: true }); 42 | exports.AppController = void 0; 43 | const openapi = require(\"@nestjs/swagger\"); 44 | const common_1 = require(\"@nestjs/common\"); 45 | const swagger_1 = require("@nestjs/swagger"); 46 | class Cat { 47 | } 48 | let AppController = class AppController { 49 | onApplicationBootstrap() { } 50 | /** 51 | * create a Cat 52 | * 53 | * @returns {Promise} 54 | * @memberof AppController 55 | */ 56 | async create() { } 57 | /** 58 | * find a Cat 59 | */ 60 | async findOne() { } 61 | /** 62 | * find all Cats im comment 63 | * 64 | * @returns {Promise} 65 | * @memberof AppController 66 | */ 67 | async findAll() { } 68 | }; 69 | __decorate([ 70 | openapi.ApiOperation({ summary: "create a Cat" }), 71 | common_1.Post(), 72 | openapi.ApiResponse({ status: 201, type: Cat }) 73 | ], AppController.prototype, \"create\", null); 74 | __decorate([ 75 | swagger_1.ApiOperation({ summary: "find a Cat" }), 76 | Get(), 77 | openapi.ApiResponse({ status: 200, type: Cat }) 78 | ], AppController.prototype, \"findOne\", null); 79 | __decorate([ 80 | swagger_1.ApiOperation({ summary: "find all Cats im comment", description: 'find all Cats' }), 81 | Get(), 82 | HttpCode(common_1.HttpStatus.NO_CONTENT), 83 | openapi.ApiResponse({ status: common_1.HttpStatus.NO_CONTENT, type: [Cat] }) 84 | ], AppController.prototype, \"findAll\", null); 85 | AppController = __decorate([ 86 | common_1.Controller('cats') 87 | ], AppController); 88 | exports.AppController = AppController; 89 | `; 90 | -------------------------------------------------------------------------------- /test/plugin/fixtures/create-cat.dto.ts: -------------------------------------------------------------------------------- 1 | export const createCatDtoText = ` 2 | import { IsInt, IsString } from 'class-validator'; 3 | 4 | enum Status { 5 | ENABLED, 6 | DISABLED 7 | } 8 | 9 | enum OneValueEnum { 10 | ONE 11 | } 12 | 13 | interface Node { 14 | id: number; 15 | } 16 | 17 | export class CreateCatDto { 18 | name: string; 19 | @Min(0) 20 | @Max(10) 21 | age: number = 3; 22 | tags: string[]; 23 | status: Status = Status.ENABLED; 24 | status2?: Status; 25 | statusArr?: Status[]; 26 | oneValueEnum?: OneValueEnum; 27 | oneValueEnumArr?: OneValueEnum[]; 28 | 29 | /** this is breed im comment */ 30 | @ApiProperty({ description: "this is breed", type: String }) 31 | @IsString() 32 | readonly breed?: string; 33 | 34 | nodes: Node[]; 35 | optionalBoolean?: boolean; 36 | date: Date; 37 | 38 | @ApiHideProperty() 39 | hidden: number; 40 | 41 | static staticProperty: string; 42 | } 43 | `; 44 | 45 | export const createCatDtoTextTranspiled = `import { IsString } from 'class-validator'; 46 | var Status; 47 | (function (Status) { 48 | Status[Status[\"ENABLED\"] = 0] = \"ENABLED\"; 49 | Status[Status[\"DISABLED\"] = 1] = \"DISABLED\"; 50 | })(Status || (Status = {})); 51 | var OneValueEnum; 52 | (function (OneValueEnum) { 53 | OneValueEnum[OneValueEnum[\"ONE\"] = 0] = \"ONE\"; 54 | })(OneValueEnum || (OneValueEnum = {})); 55 | export class CreateCatDto { 56 | constructor() { 57 | this.age = 3; 58 | this.status = Status.ENABLED; 59 | } 60 | static _OPENAPI_METADATA_FACTORY() { 61 | return { name: { required: true, type: () => String }, age: { required: true, type: () => Number, default: 3, minimum: 0, maximum: 10 }, tags: { required: true, type: () => [String] }, status: { required: true, default: Status.ENABLED, enum: Status }, status2: { required: false, enum: Status }, statusArr: { required: false, enum: Status, isArray: true }, oneValueEnum: { required: false, enum: OneValueEnum }, oneValueEnumArr: { required: false, enum: OneValueEnum }, breed: { required: false, type: () => String, title: "this is breed im comment" }, nodes: { required: true, type: () => [Object] }, optionalBoolean: { required: false, type: () => Boolean }, date: { required: true, type: () => Date } }; 62 | } 63 | } 64 | __decorate([ 65 | Min(0), 66 | Max(10) 67 | ], CreateCatDto.prototype, \"age\", void 0); 68 | __decorate([ 69 | ApiProperty({ description: "this is breed", type: String }), 70 | IsString() 71 | ], CreateCatDto.prototype, \"breed\", void 0); 72 | __decorate([ 73 | ApiHideProperty() 74 | ], CreateCatDto.prototype, \"hidden\", void 0); 75 | `; 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nestjs/swagger", 3 | "version": "4.7.16", 4 | "description": "Nest - modern, fast, powerful node.js web framework (@swagger)", 5 | "author": "Kamil Mysliwiec", 6 | "license": "MIT", 7 | "repository": "https://github.com/nestjs/swagger", 8 | "scripts": { 9 | "build": "tsc -p tsconfig.json", 10 | "format": "prettier \"lib/**/*.ts\" --write", 11 | "lint": "eslint 'lib/**/*.ts' --fix", 12 | "prepublish:next": "npm run build", 13 | "publish:next": "npm publish --access public --tag next", 14 | "prepublish:npm": "npm run build", 15 | "publish:npm": "npm publish --access public", 16 | "test": "jest", 17 | "test:dev": "jest --watch", 18 | "test:e2e": "jest --config e2e/jest-e2e.json", 19 | "prerelease": "npm run build", 20 | "release": "release-it" 21 | }, 22 | "dependencies": { 23 | "@nestjs/mapped-types": "0.4.0", 24 | "lodash": "4.17.21", 25 | "path-to-regexp": "3.2.0" 26 | }, 27 | "devDependencies": { 28 | "@commitlint/cli": "12.0.1", 29 | "@commitlint/config-angular": "12.0.1", 30 | "@nestjs/common": "7.6.14", 31 | "@nestjs/core": "7.6.14", 32 | "@nestjs/platform-express": "7.6.14", 33 | "@nestjs/platform-fastify": "7.6.14", 34 | "@types/jest": "26.0.20", 35 | "@types/lodash": "4.14.168", 36 | "@types/node": "11.15.48", 37 | "@typescript-eslint/eslint-plugin": "4.17.0", 38 | "@typescript-eslint/parser": "4.17.0", 39 | "class-transformer": "0.4.0", 40 | "class-validator": "0.13.1", 41 | "eslint": "7.22.0", 42 | "eslint-config-prettier": "8.1.0", 43 | "eslint-plugin-import": "2.22.1", 44 | "express": "4.17.1", 45 | "fastify-swagger": "4.4.1", 46 | "husky": "5.1.3", 47 | "jest": "26.6.3", 48 | "lint-staged": "10.5.4", 49 | "prettier": "2.2.1", 50 | "reflect-metadata": "0.1.13", 51 | "release-it": "14.4.1", 52 | "swagger-parser": "10.0.2", 53 | "swagger-ui-express": "4.1.6", 54 | "ts-jest": "26.5.3", 55 | "typescript": "4.2.3" 56 | }, 57 | "peerDependencies": { 58 | "@nestjs/common": "^6.8.0 || ^7.0.0", 59 | "@nestjs/core": "^6.8.0 || ^7.0.0", 60 | "fastify-swagger": "*", 61 | "reflect-metadata": "^0.1.12", 62 | "swagger-ui-express": "*" 63 | }, 64 | "peerDependenciesMeta": { 65 | "fastify-swagger": { 66 | "optional": true 67 | }, 68 | "swagger-ui-express": { 69 | "optional": true 70 | } 71 | }, 72 | "lint-staged": { 73 | "*.ts": [ 74 | "prettier --write", 75 | "git add -f" 76 | ] 77 | }, 78 | "husky": { 79 | "hooks": { 80 | "pre-commit": "lint-staged", 81 | "commit-msg": "commitlint -c .commitlintrc.json -E HUSKY_GIT_PARAMS" 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/services/parameter-metadata-accessor.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { 3 | PARAMTYPES_METADATA, 4 | ROUTE_ARGS_METADATA 5 | } from '@nestjs/common/constants'; 6 | import { RouteParamtypes } from '@nestjs/common/enums/route-paramtypes.enum'; 7 | import { isEmpty, mapValues, omitBy } from 'lodash'; 8 | import { ParameterLocation } from '../interfaces/open-api-spec.interface'; 9 | import { reverseObjectKeys } from '../utils/reverse-object-keys.util'; 10 | 11 | interface ParamMetadata { 12 | index: number; 13 | data?: string | number | object; 14 | } 15 | type ParamsMetadata = Record; 16 | 17 | export interface ParamWithTypeMetadata { 18 | name?: string | number | object; 19 | type?: Type; 20 | in?: ParameterLocation | 'body' | typeof PARAM_TOKEN_PLACEHOLDER; 21 | isArray?: boolean; 22 | required: true; 23 | enum?: unknown[]; 24 | enumName?: string; 25 | } 26 | export type ParamsWithType = Record; 27 | 28 | const PARAM_TOKEN_PLACEHOLDER = 'placeholder'; 29 | 30 | export class ParameterMetadataAccessor { 31 | explore( 32 | instance: object, 33 | prototype: Type, 34 | method: Function 35 | ): ParamsWithType { 36 | const types: Type[] = Reflect.getMetadata( 37 | PARAMTYPES_METADATA, 38 | instance, 39 | method.name 40 | ); 41 | const routeArgsMetadata: ParamsMetadata = 42 | Reflect.getMetadata( 43 | ROUTE_ARGS_METADATA, 44 | instance.constructor, 45 | method.name 46 | ) || {}; 47 | 48 | const parametersWithType: ParamsWithType = mapValues( 49 | reverseObjectKeys(routeArgsMetadata), 50 | (param: ParamMetadata) => ({ 51 | type: types[param.index], 52 | name: param.data, 53 | required: true 54 | }) 55 | ); 56 | const excludePredicate = (val: ParamWithTypeMetadata) => 57 | val.in === PARAM_TOKEN_PLACEHOLDER || (val.name && val.in === 'body'); 58 | 59 | const parameters = omitBy( 60 | mapValues(parametersWithType, (val, key) => ({ 61 | ...val, 62 | in: this.mapParamType(key) 63 | })), 64 | excludePredicate as Function 65 | ); 66 | return !isEmpty(parameters) ? (parameters as ParamsWithType) : undefined; 67 | } 68 | 69 | private mapParamType(key: string): string { 70 | const keyPair = key.split(':'); 71 | switch (Number(keyPair[0])) { 72 | case RouteParamtypes.BODY: 73 | return 'body'; 74 | case RouteParamtypes.PARAM: 75 | return 'path'; 76 | case RouteParamtypes.QUERY: 77 | return 'query'; 78 | case RouteParamtypes.HEADERS: 79 | return 'header'; 80 | default: 81 | return PARAM_TOKEN_PLACEHOLDER; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/services/response-object-factory.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, omit } from 'lodash'; 2 | import { ApiResponseMetadata, ApiResponseSchemaHost } from '../decorators'; 3 | import { SchemaObject } from '../interfaces/open-api-spec.interface'; 4 | import { isBuiltInType } from '../utils/is-built-in-type.util'; 5 | import { MimetypeContentWrapper } from './mimetype-content-wrapper'; 6 | import { ModelPropertiesAccessor } from './model-properties-accessor'; 7 | import { ResponseObjectMapper } from './response-object-mapper'; 8 | import { SchemaObjectFactory } from './schema-object-factory'; 9 | import { SwaggerTypesMapper } from './swagger-types-mapper'; 10 | 11 | export class ResponseObjectFactory { 12 | private readonly mimetypeContentWrapper = new MimetypeContentWrapper(); 13 | private readonly modelPropertiesAccessor = new ModelPropertiesAccessor(); 14 | private readonly swaggerTypesMapper = new SwaggerTypesMapper(); 15 | private readonly schemaObjectFactory = new SchemaObjectFactory( 16 | this.modelPropertiesAccessor, 17 | this.swaggerTypesMapper 18 | ); 19 | private readonly responseObjectMapper = new ResponseObjectMapper(); 20 | 21 | create( 22 | response: ApiResponseMetadata, 23 | produces: string[], 24 | schemas: SchemaObject[] 25 | ) { 26 | const { type, isArray } = response as ApiResponseMetadata; 27 | response = omit(response, ['isArray']); 28 | if (!type) { 29 | return this.responseObjectMapper.wrapSchemaWithContent( 30 | response as ApiResponseSchemaHost, 31 | produces 32 | ); 33 | } 34 | if (isBuiltInType(type as Function)) { 35 | const typeName = 36 | type && isFunction(type) ? (type as Function).name : (type as string); 37 | const swaggerType = this.swaggerTypesMapper.mapTypeToOpenAPIType( 38 | typeName 39 | ); 40 | 41 | if (isArray) { 42 | const content = this.mimetypeContentWrapper.wrap(produces, { 43 | schema: { 44 | type: 'array', 45 | items: { 46 | type: swaggerType 47 | } 48 | } 49 | }); 50 | return { 51 | ...response, 52 | ...content 53 | }; 54 | } 55 | const content = this.mimetypeContentWrapper.wrap(produces, { 56 | schema: { 57 | type: swaggerType 58 | } 59 | }); 60 | return { 61 | ...response, 62 | ...content 63 | }; 64 | } 65 | const name = this.schemaObjectFactory.exploreModelSchema( 66 | type as Function, 67 | schemas 68 | ); 69 | if (isArray) { 70 | return this.responseObjectMapper.toArrayRefObject( 71 | response, 72 | name, 73 | produces 74 | ); 75 | } 76 | return this.responseObjectMapper.toRefObject(response, name, produces); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lib/utils/enum.utils.ts: -------------------------------------------------------------------------------- 1 | import { isString } from 'lodash'; 2 | import { SchemaObject } from '../interfaces/open-api-spec.interface'; 3 | import { SchemaObjectMetadata } from '../interfaces/schema-object-metadata.interface'; 4 | import { SwaggerEnumType } from '../types/swagger-enum.type'; 5 | 6 | export function getEnumValues(enumType: SwaggerEnumType): string[] | number[] { 7 | if (Array.isArray(enumType)) { 8 | return enumType as string[]; 9 | } 10 | if (typeof enumType !== 'object') { 11 | return []; 12 | } 13 | 14 | const values = []; 15 | const uniqueValues = {}; 16 | 17 | for (const key in enumType) { 18 | const value = enumType[key]; 19 | // filter out cases where enum key also becomes its value (A: B, B: A) 20 | if ( 21 | !uniqueValues.hasOwnProperty(value) && 22 | !uniqueValues.hasOwnProperty(key) 23 | ) { 24 | values.push(value); 25 | uniqueValues[value] = value; 26 | } 27 | } 28 | return values; 29 | } 30 | 31 | export function getEnumType(values: (string | number)[]): 'string' | 'number' { 32 | const hasString = values.filter(isString).length > 0; 33 | return hasString ? 'string' : 'number'; 34 | } 35 | 36 | export function addEnumArraySchema( 37 | paramDefinition: Partial>, 38 | decoratorOptions: Partial> 39 | ) { 40 | const paramSchema: SchemaObject = paramDefinition.schema || {}; 41 | paramDefinition.schema = paramSchema; 42 | paramSchema.type = 'array'; 43 | delete paramDefinition.isArray; 44 | 45 | const enumValues = getEnumValues(decoratorOptions.enum); 46 | paramSchema.items = { 47 | type: getEnumType(enumValues), 48 | enum: enumValues 49 | }; 50 | 51 | if (decoratorOptions.enumName) { 52 | paramDefinition.enumName = decoratorOptions.enumName; 53 | } 54 | } 55 | 56 | export function addEnumSchema( 57 | paramDefinition: Partial>, 58 | decoratorOptions: Partial> 59 | ) { 60 | const paramSchema: SchemaObject = paramDefinition.schema || {}; 61 | const enumValues = getEnumValues(decoratorOptions.enum); 62 | 63 | paramDefinition.schema = paramSchema; 64 | paramSchema.enum = enumValues; 65 | paramSchema.type = getEnumType(enumValues); 66 | 67 | if (decoratorOptions.enumName) { 68 | paramDefinition.enumName = decoratorOptions.enumName; 69 | } 70 | } 71 | 72 | export const isEnumArray = >>( 73 | obj: Record 74 | ): obj is T => obj.isArray && obj.enum; 75 | 76 | export const isEnumDefined = >>( 77 | obj: Record 78 | ): obj is T => obj.enum; 79 | 80 | export const isEnumMetadata = (metadata: SchemaObjectMetadata) => 81 | metadata.enum || (metadata.isArray && metadata.items?.['enum']); 82 | -------------------------------------------------------------------------------- /test/plugin/fixtures/create-cat-alt2.dto.ts: -------------------------------------------------------------------------------- 1 | export const createCatDtoAlt2Text = ` 2 | import { CreateDateColumn, UpdateDateColumn, VersionColumn } from 'typeorm'; 3 | 4 | export abstract class Audit { 5 | /** test on createdAt */ 6 | @CreateDateColumn() 7 | createdAt; 8 | 9 | // commentedOutProperty: string; 10 | 11 | // test on updatedAt1 12 | // test on updatedAt2 13 | @UpdateDateColumn() 14 | updatedAt; 15 | 16 | /** 17 | * test 18 | * version 19 | * @example 'version 123' 20 | * @example ignore this 21 | * @memberof Audit 22 | */ 23 | @VersionColumn() 24 | version: string 25 | 26 | /** 27 | * testVersion 28 | * 29 | * @example '0.0.1' 30 | * @example '0.0.2' 31 | * @memberof Audit 32 | */ 33 | testVersion; 34 | 35 | /** 36 | * testVersionArray 37 | * 38 | * @example ['0.0.1', '0.0.2'] 39 | * @memberof Audit 40 | */ 41 | testVersionArray: string[]; 42 | 43 | /** 44 | * testVersionArray 45 | * 46 | * @example ['version 123', 'version 321'] 47 | * @memberof Audit 48 | */ 49 | testVersionArray2: string[]; 50 | 51 | /** 52 | * testVersionArray 53 | * 54 | * @example [123, 321] 55 | * @memberof Audit 56 | */ 57 | testVersionArray3: number[]; 58 | 59 | /** 60 | * testBoolean 61 | * 62 | * @example true 63 | */ 64 | testBoolean: boolean; 65 | 66 | /** 67 | * testNumber 68 | * 69 | * @example 1.0 70 | * @example 5 71 | */ 72 | testNumber: number; 73 | } 74 | `; 75 | 76 | export const createCatDtoTextAlt2Transpiled = `import { CreateDateColumn, UpdateDateColumn, VersionColumn } from 'typeorm'; 77 | export class Audit { 78 | static _OPENAPI_METADATA_FACTORY() { 79 | return { createdAt: { required: true, type: () => Object, description: "test on createdAt" }, updatedAt: { required: true, type: () => Object }, version: { required: true, type: () => String, description: "test\\nversion", example: "version 123" }, testVersion: { required: true, type: () => Object, description: "testVersion", examples: ["0.0.1", "0.0.2"] }, testVersionArray: { required: true, type: () => [String], description: "testVersionArray", example: ["0.0.1", "0.0.2"] }, testVersionArray2: { required: true, type: () => [String], description: "testVersionArray", example: ["version 123", "version 321"] }, testVersionArray3: { required: true, type: () => [Number], description: "testVersionArray", example: [123, 321] }, testBoolean: { required: true, type: () => Boolean, description: "testBoolean", example: true }, testNumber: { required: true, type: () => Number, description: "testNumber", examples: [1, 5] } }; 80 | } 81 | } 82 | __decorate([ 83 | CreateDateColumn() 84 | ], Audit.prototype, "createdAt", void 0); 85 | __decorate([ 86 | UpdateDateColumn() 87 | ], Audit.prototype, "updatedAt", void 0); 88 | __decorate([ 89 | VersionColumn() 90 | ], Audit.prototype, "version", void 0); 91 | `; 92 | -------------------------------------------------------------------------------- /lib/explorers/api-response.explorer.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, RequestMethod, Type } from '@nestjs/common'; 2 | import { HTTP_CODE_METADATA, METHOD_METADATA } from '@nestjs/common/constants'; 3 | import { isEmpty } from '@nestjs/common/utils/shared.utils'; 4 | import { get, mapValues, omit } from 'lodash'; 5 | import { DECORATORS } from '../constants'; 6 | import { ApiResponseMetadata } from '../decorators'; 7 | import { SchemaObject } from '../interfaces/open-api-spec.interface'; 8 | import { ResponseObjectFactory } from '../services/response-object-factory'; 9 | import { mergeAndUniq } from '../utils/merge-and-uniq.util'; 10 | 11 | const responseObjectFactory = new ResponseObjectFactory(); 12 | 13 | export const exploreGlobalApiResponseMetadata = ( 14 | schemas: SchemaObject[], 15 | metatype: Type 16 | ) => { 17 | const responses: ApiResponseMetadata[] = Reflect.getMetadata( 18 | DECORATORS.API_RESPONSE, 19 | metatype 20 | ); 21 | const produces = Reflect.getMetadata(DECORATORS.API_PRODUCES, metatype); 22 | return responses 23 | ? { 24 | responses: mapResponsesToSwaggerResponses(responses, schemas, produces) 25 | } 26 | : undefined; 27 | }; 28 | 29 | export const exploreApiResponseMetadata = ( 30 | schemas: SchemaObject[], 31 | instance: object, 32 | prototype: Type, 33 | method: Function 34 | ) => { 35 | const responses = Reflect.getMetadata(DECORATORS.API_RESPONSE, method); 36 | if (responses) { 37 | const classProduces = Reflect.getMetadata( 38 | DECORATORS.API_PRODUCES, 39 | prototype 40 | ); 41 | const methodProduces = Reflect.getMetadata(DECORATORS.API_PRODUCES, method); 42 | const produces = mergeAndUniq( 43 | get(classProduces, 'produces'), 44 | methodProduces 45 | ); 46 | return mapResponsesToSwaggerResponses(responses, schemas, produces); 47 | } 48 | const status = getStatusCode(method); 49 | if (status) { 50 | return { [status]: { description: '' } }; 51 | } 52 | return undefined; 53 | }; 54 | 55 | const getStatusCode = (method: Function) => { 56 | const status = Reflect.getMetadata(HTTP_CODE_METADATA, method); 57 | if (status) { 58 | return status; 59 | } 60 | const requestMethod: RequestMethod = Reflect.getMetadata( 61 | METHOD_METADATA, 62 | method 63 | ); 64 | switch (requestMethod) { 65 | case RequestMethod.POST: 66 | return HttpStatus.CREATED; 67 | default: 68 | return HttpStatus.OK; 69 | } 70 | }; 71 | 72 | const omitParamType = (param: Record) => omit(param, 'type'); 73 | const mapResponsesToSwaggerResponses = ( 74 | responses: ApiResponseMetadata[], 75 | schemas: SchemaObject[], 76 | produces: string[] = ['application/json'] 77 | ) => { 78 | produces = isEmpty(produces) ? ['application/json'] : produces; 79 | 80 | const openApiResponses = mapValues( 81 | responses, 82 | (response: ApiResponseMetadata) => 83 | responseObjectFactory.create(response, produces, schemas) 84 | ); 85 | return mapValues(openApiResponses, omitParamType); 86 | }; 87 | -------------------------------------------------------------------------------- /test/type-helpers/partial-type.helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { Expose, Transform } from 'class-transformer'; 3 | import { IsString, validate } from 'class-validator'; 4 | import { DECORATORS } from '../../lib/constants'; 5 | import { ApiProperty } from '../../lib/decorators'; 6 | import { METADATA_FACTORY_NAME } from '../../lib/plugin/plugin-constants'; 7 | import { ModelPropertiesAccessor } from '../../lib/services/model-properties-accessor'; 8 | import { PartialType } from '../../lib/type-helpers'; 9 | 10 | describe('PartialType', () => { 11 | class CreateUserDto { 12 | @IsString() 13 | firstName: string; 14 | 15 | @IsString() 16 | lastName: string; 17 | 18 | @ApiProperty({ required: true }) 19 | login: string; 20 | 21 | @Expose() 22 | @Transform((str) => str + '_transformed') 23 | @IsString() 24 | @ApiProperty({ minLength: 10 }) 25 | password: string; 26 | 27 | static [METADATA_FACTORY_NAME]() { 28 | return { 29 | firstName: { required: true, type: String }, 30 | lastName: { required: true, type: String } 31 | }; 32 | } 33 | } 34 | 35 | class UpdateUserDto extends PartialType(CreateUserDto) {} 36 | 37 | let modelPropertiesAccessor: ModelPropertiesAccessor; 38 | 39 | beforeEach(() => { 40 | modelPropertiesAccessor = new ModelPropertiesAccessor(); 41 | }); 42 | 43 | describe('Validation metadata', () => { 44 | it('should apply @IsOptional to properties reflected by the plugin', async () => { 45 | const updateDto = new UpdateUserDto(); 46 | const validationErrors = await validate(updateDto); 47 | expect(validationErrors).toHaveLength(0); 48 | }); 49 | }); 50 | describe('OpenAPI metadata', () => { 51 | it('should return partial class', () => { 52 | const prototype = (UpdateUserDto.prototype as any) as Type; 53 | 54 | modelPropertiesAccessor.applyMetadataFactory(prototype); 55 | expect(modelPropertiesAccessor.getModelProperties(prototype)).toEqual([ 56 | 'login', 57 | 'password', 58 | 'firstName', 59 | 'lastName' 60 | ]); 61 | }); 62 | 63 | it('should set "required" option to "false" for each property', () => { 64 | const classRef = (UpdateUserDto.prototype as any) as Type; 65 | const keys = modelPropertiesAccessor.getModelProperties(classRef); 66 | const metadata = keys.map((key) => { 67 | return Reflect.getMetadata( 68 | DECORATORS.API_MODEL_PROPERTIES, 69 | classRef, 70 | key 71 | ); 72 | }); 73 | 74 | expect(metadata[0]).toEqual({ 75 | isArray: false, 76 | required: false, 77 | type: String 78 | }); 79 | expect(metadata[1]).toEqual({ 80 | isArray: false, 81 | required: false, 82 | minLength: 10, 83 | type: String 84 | }); 85 | expect(metadata[2]).toEqual({ 86 | isArray: false, 87 | required: false, 88 | type: String 89 | }); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /lib/swagger-module.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { loadPackage } from '@nestjs/common/utils/load-package.util'; 3 | import { 4 | OpenAPIObject, 5 | SwaggerCustomOptions, 6 | SwaggerDocumentOptions 7 | } from './interfaces'; 8 | import { SwaggerScanner } from './swagger-scanner'; 9 | import { validatePath } from './utils/validate-path.util'; 10 | 11 | export class SwaggerModule { 12 | public static createDocument( 13 | app: INestApplication, 14 | config: Omit, 15 | options: SwaggerDocumentOptions = {} 16 | ): OpenAPIObject { 17 | const swaggerScanner = new SwaggerScanner(); 18 | const document = swaggerScanner.scanApplication(app, options); 19 | document.components = { 20 | ...(config.components || {}), 21 | ...document.components 22 | }; 23 | return { 24 | openapi: '3.0.0', 25 | ...config, 26 | ...document 27 | }; 28 | } 29 | 30 | public static setup( 31 | path: string, 32 | app: INestApplication, 33 | document: OpenAPIObject, 34 | options?: SwaggerCustomOptions 35 | ) { 36 | const httpAdapter = app.getHttpAdapter(); 37 | if (httpAdapter && httpAdapter.getType() === 'fastify') { 38 | return this.setupFastify(path, httpAdapter, document); 39 | } 40 | return this.setupExpress(path, app, document, options); 41 | } 42 | 43 | private static setupExpress( 44 | path: string, 45 | app: INestApplication, 46 | document: OpenAPIObject, 47 | options?: SwaggerCustomOptions 48 | ) { 49 | const httpAdapter = app.getHttpAdapter(); 50 | const finalPath = validatePath(path); 51 | const swaggerUi = loadPackage('swagger-ui-express', 'SwaggerModule', () => 52 | require('swagger-ui-express') 53 | ); 54 | const swaggerHtml = swaggerUi.generateHTML(document, options); 55 | app.use(finalPath, swaggerUi.serveFiles(document, options)); 56 | 57 | httpAdapter.get(finalPath, (req, res) => res.send(swaggerHtml)); 58 | httpAdapter.get(finalPath + '-json', (req, res) => res.json(document)); 59 | } 60 | 61 | private static setupFastify( 62 | path: string, 63 | httpServer: any, 64 | document: OpenAPIObject 65 | ) { 66 | // Workaround for older versions of the @nestjs/platform-fastify package 67 | // where "isParserRegistered" getter is not defined. 68 | const hasParserGetterDefined = (Object.getPrototypeOf( 69 | httpServer 70 | ) as Object).hasOwnProperty('isParserRegistered'); 71 | if (hasParserGetterDefined && !httpServer.isParserRegistered) { 72 | httpServer.registerParserMiddleware(); 73 | } 74 | 75 | httpServer.register(async (httpServer: any) => { 76 | httpServer.register( 77 | loadPackage('fastify-swagger', 'SwaggerModule', () => 78 | require('fastify-swagger') 79 | ), 80 | { 81 | swagger: document, 82 | exposeRoute: true, 83 | routePrefix: path, 84 | mode: 'static', 85 | specification: { 86 | document 87 | } 88 | } 89 | ); 90 | }); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/explorers/api-parameters.explorer.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { assign, find, isNil, map, omitBy, some, unionWith } from 'lodash'; 3 | import { DECORATORS } from '../constants'; 4 | import { SchemaObject } from '../interfaces/open-api-spec.interface'; 5 | import { ModelPropertiesAccessor } from '../services/model-properties-accessor'; 6 | import { 7 | ParameterMetadataAccessor, 8 | ParamWithTypeMetadata 9 | } from '../services/parameter-metadata-accessor'; 10 | import { ParametersMetadataMapper } from '../services/parameters-metadata-mapper'; 11 | import { SchemaObjectFactory } from '../services/schema-object-factory'; 12 | import { SwaggerTypesMapper } from '../services/swagger-types-mapper'; 13 | 14 | const parameterMetadataAccessor = new ParameterMetadataAccessor(); 15 | const modelPropertiesAccessor = new ModelPropertiesAccessor(); 16 | const parametersMetadataMapper = new ParametersMetadataMapper( 17 | modelPropertiesAccessor 18 | ); 19 | const swaggerTypesMapper = new SwaggerTypesMapper(); 20 | const schemaObjectFactory = new SchemaObjectFactory( 21 | modelPropertiesAccessor, 22 | swaggerTypesMapper 23 | ); 24 | 25 | export const exploreApiParametersMetadata = ( 26 | schemas: SchemaObject[], 27 | schemaRefsStack: [], 28 | instance: object, 29 | prototype: Type, 30 | method: Function 31 | ) => { 32 | const explicitParameters: any[] = Reflect.getMetadata( 33 | DECORATORS.API_PARAMETERS, 34 | method 35 | ); 36 | const parametersMetadata = parameterMetadataAccessor.explore( 37 | instance, 38 | prototype, 39 | method 40 | ); 41 | const noExplicitMetadata = isNil(explicitParameters); 42 | if (noExplicitMetadata && isNil(parametersMetadata)) { 43 | return undefined; 44 | } 45 | const reflectedParametersAsProperties = parametersMetadataMapper.transformModelToProperties( 46 | parametersMetadata || {} 47 | ); 48 | 49 | let properties = reflectedParametersAsProperties; 50 | if (!noExplicitMetadata) { 51 | const mergeImplicitAndExplicit = (item: ParamWithTypeMetadata) => 52 | assign(item, find(explicitParameters, ['name', item.name])); 53 | 54 | properties = removeBodyMetadataIfExplicitExists( 55 | properties, 56 | explicitParameters 57 | ); 58 | properties = map(properties, mergeImplicitAndExplicit); 59 | properties = unionWith(properties, explicitParameters, (arrVal, othVal) => { 60 | return arrVal.name === othVal.name && arrVal.in === othVal.in; 61 | }); 62 | } 63 | 64 | const paramsWithDefinitions = schemaObjectFactory.createFromModel( 65 | properties, 66 | schemas, 67 | schemaRefsStack 68 | ); 69 | const parameters = swaggerTypesMapper.mapParamTypes(paramsWithDefinitions); 70 | return parameters ? { parameters } : undefined; 71 | }; 72 | 73 | function removeBodyMetadataIfExplicitExists( 74 | properties: ParamWithTypeMetadata[], 75 | explicitParams: any[] 76 | ) { 77 | const isBodyReflected = some(properties, (p) => p.in === 'body'); 78 | const isBodyDefinedExplicitly = some(explicitParams, (p) => p.in === 'body'); 79 | if (isBodyReflected && isBodyDefinedExplicitly) { 80 | return omitBy( 81 | properties, 82 | (p) => p.in === 'body' 83 | ) as ParamWithTypeMetadata[]; 84 | } 85 | return properties; 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 |

A progressive Node.js framework for building efficient and scalable server-side applications.

6 |

7 | NPM Version 8 | Package License 9 | NPM Downloads 10 | CircleCI 11 | Coverage 12 | Discord 13 | Backers on Open Collective 14 | Sponsors on Open Collective 15 | 16 | 17 |

18 | 20 | 21 | ## Description 22 | 23 | [OpenAPI (Swagger)](https://www.openapis.org/) module for [Nest](https://github.com/nestjs/nest). 24 | 25 | ## Installation 26 | 27 | ```bash 28 | $ npm i --save @nestjs/swagger 29 | ``` 30 | 31 | ## Quick Start 32 | 33 | [Overview & Tutorial](https://docs.nestjs.com/openapi/introduction) 34 | 35 | ## Migration from v3 36 | 37 | If you're currently using `@nestjs/swagger@3.*`, note the following breaking/API changes in version 4.0. 38 | 39 | The following decorators have been changed/renamed: 40 | 41 | - `@ApiModelProperty` is now `@ApiProperty` 42 | - `@ApiModelPropertyOptional` is now `@ApiPropertyOptional` 43 | - `@ApiResponseModelProperty` is now `@ApiResponseProperty` 44 | - `@ApiImplicitQuery` is now `@ApiQuery` 45 | - `@ApiImplicitParam` is now `@ApiParam` 46 | - `@ApiImplicitBody` is now `@ApiBody` 47 | - `@ApiImplicitHeader` is now `@ApiHeader` 48 | - `@ApiOperation({ title: 'test' })` is now `@ApiOperation({ summary: 'test' })` 49 | - `@ApiUseTags` is now `@ApiTags` 50 | 51 | `DocumentBuilder` breaking changes (updated method signatures): 52 | 53 | - `addTag` 54 | - `addBearerAuth` 55 | - `addOAuth2` 56 | - `setContactEmail` is now `setContact` 57 | - `setHost` has been removed 58 | - `setSchemes` has been removed (use the `addServer` instead, e.g., `addServer('http://')`) 59 | 60 | The following methods have been added: 61 | 62 | - `addServer` 63 | - `addApiKey` 64 | - `addBasicAuth` 65 | - `addSecurity` 66 | - `addSecurityRequirements` 67 | 68 | ## Support 69 | 70 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 71 | 72 | ## Stay in touch 73 | 74 | - Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) 75 | - Website - [https://nestjs.com](https://nestjs.com/) 76 | - Twitter - [@nestframework](https://twitter.com/nestframework) 77 | 78 | ## License 79 | 80 | Nest is [MIT licensed](LICENSE). 81 | -------------------------------------------------------------------------------- /lib/decorators/helpers.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isUndefined, negate, pickBy } from 'lodash'; 2 | import { DECORATORS } from '../constants'; 3 | import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants'; 4 | 5 | export function createMethodDecorator( 6 | metakey: string, 7 | metadata: T 8 | ): MethodDecorator { 9 | return ( 10 | target: object, 11 | key: string | symbol, 12 | descriptor: PropertyDescriptor 13 | ) => { 14 | Reflect.defineMetadata(metakey, metadata, descriptor.value); 15 | return descriptor; 16 | }; 17 | } 18 | 19 | export function createClassDecorator = any>( 20 | metakey: string, 21 | metadata: T = [] as T 22 | ): ClassDecorator { 23 | return (target) => { 24 | const prevValue = Reflect.getMetadata(metakey, target) || []; 25 | Reflect.defineMetadata(metakey, [...prevValue, ...metadata], target); 26 | return target; 27 | }; 28 | } 29 | 30 | export function createPropertyDecorator = any>( 31 | metakey: string, 32 | metadata: T, 33 | overrideExisting = true 34 | ): PropertyDecorator { 35 | return (target: object, propertyKey: string) => { 36 | const properties = 37 | Reflect.getMetadata(DECORATORS.API_MODEL_PROPERTIES_ARRAY, target) || []; 38 | 39 | const key = `:${propertyKey}`; 40 | if (!properties.includes(key)) { 41 | Reflect.defineMetadata( 42 | DECORATORS.API_MODEL_PROPERTIES_ARRAY, 43 | [...properties, `:${propertyKey}`], 44 | target 45 | ); 46 | } 47 | const existingMetadata = Reflect.getMetadata(metakey, target, propertyKey); 48 | if (existingMetadata) { 49 | const newMetadata = pickBy(metadata, negate(isUndefined)); 50 | const metadataToSave = overrideExisting 51 | ? { 52 | ...existingMetadata, 53 | ...newMetadata 54 | } 55 | : { 56 | ...newMetadata, 57 | ...existingMetadata 58 | }; 59 | 60 | Reflect.defineMetadata(metakey, metadataToSave, target, propertyKey); 61 | } else { 62 | const type = 63 | target?.constructor?.[METADATA_FACTORY_NAME]?.()[propertyKey]?.type ?? 64 | Reflect.getMetadata('design:type', target, propertyKey); 65 | 66 | Reflect.defineMetadata( 67 | metakey, 68 | { 69 | type, 70 | ...pickBy(metadata, negate(isUndefined)) 71 | }, 72 | target, 73 | propertyKey 74 | ); 75 | } 76 | }; 77 | } 78 | 79 | export function createMixedDecorator( 80 | metakey: string, 81 | metadata: T 82 | ): MethodDecorator & ClassDecorator { 83 | return ( 84 | target: object, 85 | key?: string | symbol, 86 | descriptor?: TypedPropertyDescriptor 87 | ): any => { 88 | if (descriptor) { 89 | Reflect.defineMetadata(metakey, metadata, descriptor.value); 90 | return descriptor; 91 | } 92 | Reflect.defineMetadata(metakey, metadata, target); 93 | return target; 94 | }; 95 | } 96 | 97 | export function createParamDecorator = any>( 98 | metadata: T, 99 | initial: Partial 100 | ): MethodDecorator { 101 | return ( 102 | target: object, 103 | key: string | symbol, 104 | descriptor: PropertyDescriptor 105 | ) => { 106 | const parameters = 107 | Reflect.getMetadata(DECORATORS.API_PARAMETERS, descriptor.value) || []; 108 | Reflect.defineMetadata( 109 | DECORATORS.API_PARAMETERS, 110 | [ 111 | ...parameters, 112 | { 113 | ...initial, 114 | ...pickBy(metadata, negate(isUndefined)) 115 | } 116 | ], 117 | descriptor.value 118 | ); 119 | return descriptor; 120 | }; 121 | } 122 | 123 | export function getTypeIsArrayTuple( 124 | input: Function | [Function] | undefined | string | Record, 125 | isArrayFlag: boolean 126 | ): [Function | undefined, boolean] { 127 | if (!input) { 128 | return [input as undefined, isArrayFlag]; 129 | } 130 | if (isArrayFlag) { 131 | return [input as Function, isArrayFlag]; 132 | } 133 | const isInputArray = isArray(input); 134 | const type = isInputArray ? input[0] : input; 135 | return [type, isInputArray]; 136 | } 137 | -------------------------------------------------------------------------------- /lib/swagger-scanner.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Type } from '@nestjs/common'; 2 | import { MODULE_PATH } from '@nestjs/common/constants'; 3 | import { NestContainer } from '@nestjs/core/injector/container'; 4 | import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; 5 | import { Module } from '@nestjs/core/injector/module'; 6 | import { extend, flatten, isEmpty, reduce } from 'lodash'; 7 | import { OpenAPIObject, SwaggerDocumentOptions } from './interfaces'; 8 | import { 9 | ReferenceObject, 10 | SchemaObject 11 | } from './interfaces/open-api-spec.interface'; 12 | import { ModelPropertiesAccessor } from './services/model-properties-accessor'; 13 | import { SchemaObjectFactory } from './services/schema-object-factory'; 14 | import { SwaggerTypesMapper } from './services/swagger-types-mapper'; 15 | import { SwaggerExplorer } from './swagger-explorer'; 16 | import { SwaggerTransformer } from './swagger-transformer'; 17 | import { stripLastSlash } from './utils/strip-last-slash.util'; 18 | 19 | export class SwaggerScanner { 20 | private readonly transfomer = new SwaggerTransformer(); 21 | private readonly schemaObjectFactory = new SchemaObjectFactory( 22 | new ModelPropertiesAccessor(), 23 | new SwaggerTypesMapper() 24 | ); 25 | private readonly explorer = new SwaggerExplorer(this.schemaObjectFactory); 26 | 27 | public scanApplication( 28 | app: INestApplication, 29 | options: SwaggerDocumentOptions 30 | ): Omit { 31 | const { 32 | deepScanRoutes, 33 | include: includedModules = [], 34 | extraModels = [], 35 | ignoreGlobalPrefix = false, 36 | operationIdFactory 37 | } = options; 38 | 39 | const container: NestContainer = (app as any).container; 40 | const modules: Module[] = this.getModules( 41 | container.getModules(), 42 | includedModules 43 | ); 44 | const globalPrefix = !ignoreGlobalPrefix 45 | ? stripLastSlash(this.getGlobalPrefix(app)) 46 | : ''; 47 | 48 | const denormalizedPaths = modules.map( 49 | ({ routes, metatype, relatedModules }) => { 50 | let allRoutes = new Map(routes); 51 | 52 | if (deepScanRoutes) { 53 | // only load submodules routes if asked 54 | const isGlobal = (module: Type) => 55 | !container.isGlobalModule(module); 56 | 57 | Array.from(relatedModules.values()) 58 | .filter(isGlobal as any) 59 | .map(({ routes: relatedModuleRoutes }) => relatedModuleRoutes) 60 | .forEach((relatedModuleRoutes) => { 61 | allRoutes = new Map([...allRoutes, ...relatedModuleRoutes]); 62 | }); 63 | } 64 | const path = metatype 65 | ? Reflect.getMetadata(MODULE_PATH, metatype) 66 | : undefined; 67 | 68 | return this.scanModuleRoutes( 69 | allRoutes, 70 | path, 71 | globalPrefix, 72 | operationIdFactory 73 | ); 74 | } 75 | ); 76 | 77 | const schemas = this.explorer.getSchemas(); 78 | this.addExtraModels(schemas, extraModels); 79 | 80 | return { 81 | ...this.transfomer.normalizePaths(flatten(denormalizedPaths)), 82 | components: { 83 | schemas: reduce(this.explorer.getSchemas(), extend) as Record< 84 | string, 85 | SchemaObject | ReferenceObject 86 | > 87 | } 88 | }; 89 | } 90 | 91 | public scanModuleRoutes( 92 | routes: Map, 93 | modulePath?: string, 94 | globalPrefix?: string, 95 | operationIdFactory?: (controllerKey: string, methodKey: string) => string 96 | ): Array & Record<'root', any>> { 97 | const denormalizedArray = [...routes.values()].map((ctrl) => 98 | this.explorer.exploreController( 99 | ctrl, 100 | modulePath, 101 | globalPrefix, 102 | operationIdFactory 103 | ) 104 | ); 105 | return flatten(denormalizedArray) as any; 106 | } 107 | 108 | public getModules( 109 | modulesContainer: Map, 110 | include: Function[] 111 | ): Module[] { 112 | if (!include || isEmpty(include)) { 113 | return [...modulesContainer.values()]; 114 | } 115 | return [...modulesContainer.values()].filter(({ metatype }) => 116 | include.some((item) => item === metatype) 117 | ); 118 | } 119 | 120 | public addExtraModels(schemas: SchemaObject[], extraModels: Function[]) { 121 | extraModels.forEach((item) => { 122 | this.schemaObjectFactory.exploreModelSchema(item, schemas); 123 | }); 124 | } 125 | 126 | private getGlobalPrefix(app: INestApplication): string { 127 | const internalConfigRef = (app as any).config; 128 | return (internalConfigRef && internalConfigRef.getGlobalPrefix()) || ''; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /lib/services/swagger-types-mapper.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, isUndefined, omit, omitBy } from 'lodash'; 2 | import { ApiPropertyOptions } from '../decorators'; 3 | import { 4 | BaseParameterObject, 5 | ReferenceObject, 6 | SchemaObject 7 | } from '../interfaces/open-api-spec.interface'; 8 | import { ParamWithTypeMetadata } from './parameter-metadata-accessor'; 9 | 10 | export class SwaggerTypesMapper { 11 | mapParamTypes( 12 | parameters: Array 13 | ) { 14 | return parameters.map((param) => { 15 | if (this.hasSchemaDefinition(param as BaseParameterObject)) { 16 | return this.omitParamType(param); 17 | } 18 | const { type } = param as ParamWithTypeMetadata; 19 | const typeName = 20 | type && isFunction(type) 21 | ? this.mapTypeToOpenAPIType(type.name) 22 | : this.mapTypeToOpenAPIType(type); 23 | 24 | const paramWithTypeMetadata = omitBy( 25 | { 26 | ...param, 27 | type: typeName 28 | }, 29 | isUndefined 30 | ); 31 | 32 | const keysToRemove: Array = [ 33 | 'type', 34 | 'isArray', 35 | 'enum', 36 | 'items', 37 | '$ref', 38 | ...this.getSchemaOptionsKeys() 39 | ]; 40 | if (this.isEnumArrayType(paramWithTypeMetadata)) { 41 | return this.mapEnumArrayType( 42 | paramWithTypeMetadata as ParamWithTypeMetadata, 43 | keysToRemove 44 | ); 45 | } else if (paramWithTypeMetadata.isArray) { 46 | return this.mapArrayType( 47 | paramWithTypeMetadata as ParamWithTypeMetadata, 48 | keysToRemove 49 | ); 50 | } 51 | return { 52 | ...omit(param, keysToRemove), 53 | schema: omitBy( 54 | { 55 | ...this.getSchemaOptions(param), 56 | ...((param as BaseParameterObject).schema || {}), 57 | enum: paramWithTypeMetadata.enum, 58 | type: paramWithTypeMetadata.type, 59 | $ref: (paramWithTypeMetadata as ReferenceObject).$ref 60 | }, 61 | isUndefined 62 | ) 63 | }; 64 | }); 65 | } 66 | 67 | mapTypeToOpenAPIType(type: string | Function): string { 68 | if (!(type && (type as string).charAt)) { 69 | return; 70 | } 71 | return (type as string).charAt(0).toLowerCase() + (type as string).slice(1); 72 | } 73 | 74 | mapEnumArrayType( 75 | param: Record, 76 | keysToRemove: Array 77 | ) { 78 | return { 79 | ...omit(param, keysToRemove), 80 | schema: { 81 | ...this.getSchemaOptions(param), 82 | type: 'array', 83 | items: param.items 84 | } 85 | }; 86 | } 87 | 88 | mapArrayType( 89 | param: (ParamWithTypeMetadata & SchemaObject) | BaseParameterObject, 90 | keysToRemove: Array 91 | ) { 92 | const items = 93 | (param as SchemaObject).items || 94 | omitBy( 95 | { 96 | ...((param as BaseParameterObject).schema || {}), 97 | enum: (param as ParamWithTypeMetadata).enum, 98 | type: this.mapTypeToOpenAPIType((param as ParamWithTypeMetadata).type) 99 | }, 100 | isUndefined 101 | ); 102 | return { 103 | ...omit(param, keysToRemove), 104 | schema: { 105 | ...this.getSchemaOptions(param), 106 | type: 'array', 107 | items 108 | } 109 | }; 110 | } 111 | 112 | private getSchemaOptions(param: Record): Partial { 113 | const schemaKeys = this.getSchemaOptionsKeys(); 114 | const optionsObject: Partial = schemaKeys.reduce( 115 | (acc, key) => ({ 116 | ...acc, 117 | [key]: param[key] 118 | }), 119 | {} 120 | ); 121 | return omitBy(optionsObject, isUndefined); 122 | } 123 | 124 | private isEnumArrayType(param: Record): boolean { 125 | return param.isArray && param.items && param.items.enum; 126 | } 127 | 128 | private hasSchemaDefinition( 129 | param: BaseParameterObject 130 | ): param is BaseParameterObject { 131 | return !!param.schema; 132 | } 133 | 134 | private omitParamType(param: ParamWithTypeMetadata | BaseParameterObject) { 135 | return omit(param, 'type'); 136 | } 137 | 138 | private getSchemaOptionsKeys(): Array { 139 | return [ 140 | 'additionalProperties', 141 | 'minimum', 142 | 'maximum', 143 | 'maxProperties', 144 | 'minItems', 145 | 'minProperties', 146 | 'maxItems', 147 | 'exclusiveMaximum', 148 | 'exclusiveMinimum', 149 | 'uniqueItems', 150 | 'title', 151 | 'format', 152 | 'pattern', 153 | 'default' 154 | ]; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /lib/document-builder.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { isUndefined, negate, pickBy } from 'lodash'; 3 | import { buildDocumentBase } from './fixtures/document.base'; 4 | import { OpenAPIObject } from './interfaces'; 5 | import { 6 | ExternalDocumentationObject, 7 | SecuritySchemeObject, 8 | ServerVariableObject, 9 | TagObject 10 | } from './interfaces/open-api-spec.interface'; 11 | 12 | export class DocumentBuilder { 13 | private readonly logger = new Logger(DocumentBuilder.name); 14 | private readonly document: Omit = buildDocumentBase(); 15 | 16 | public setTitle(title: string): this { 17 | this.document.info.title = title; 18 | return this; 19 | } 20 | 21 | public setDescription(description: string): this { 22 | this.document.info.description = description; 23 | return this; 24 | } 25 | 26 | public setVersion(version: string): this { 27 | this.document.info.version = version; 28 | return this; 29 | } 30 | 31 | public setTermsOfService(termsOfService: string): this { 32 | this.document.info.termsOfService = termsOfService; 33 | return this; 34 | } 35 | 36 | public setContact(name: string, url: string, email: string): this { 37 | this.document.info.contact = { name, url, email }; 38 | return this; 39 | } 40 | 41 | public setLicense(name: string, url: string): this { 42 | this.document.info.license = { name, url }; 43 | return this; 44 | } 45 | 46 | public addServer( 47 | url: string, 48 | description?: string, 49 | variables?: Record 50 | ): this { 51 | this.document.servers.push({ url, description, variables }); 52 | return this; 53 | } 54 | 55 | public setExternalDoc(description: string, url: string): this { 56 | this.document.externalDocs = { description, url }; 57 | return this; 58 | } 59 | 60 | public setBasePath(path: string) { 61 | this.logger.warn( 62 | 'The "setBasePath" method has been deprecated. Now, a global prefix is populated automatically. If you want to ignore it, take a look here: https://docs.nestjs.com/recipes/swagger#global-prefix. Alternatively, you can use "addServer" method to set up multiple different paths.' 63 | ); 64 | return this; 65 | } 66 | 67 | public addTag( 68 | name: string, 69 | description = '', 70 | externalDocs?: ExternalDocumentationObject 71 | ): this { 72 | this.document.tags = this.document.tags.concat( 73 | pickBy( 74 | { 75 | name, 76 | description, 77 | externalDocs 78 | }, 79 | negate(isUndefined) 80 | ) as TagObject 81 | ); 82 | return this; 83 | } 84 | 85 | public addSecurity(name: string, options: SecuritySchemeObject): this { 86 | this.document.components.securitySchemes = { 87 | ...(this.document.components.securitySchemes || {}), 88 | [name]: options 89 | }; 90 | return this; 91 | } 92 | 93 | public addSecurityRequirements( 94 | name: string, 95 | requirements: string[] = [] 96 | ): this { 97 | this.document.security = (this.document.security || []).concat({ 98 | [name]: requirements 99 | }); 100 | return this; 101 | } 102 | 103 | public addBearerAuth( 104 | options: SecuritySchemeObject = { 105 | type: 'http' 106 | }, 107 | name = 'bearer' 108 | ): this { 109 | this.addSecurity(name, { 110 | scheme: 'bearer', 111 | bearerFormat: 'JWT', 112 | ...options 113 | }); 114 | return this; 115 | } 116 | 117 | public addOAuth2( 118 | options: SecuritySchemeObject = { 119 | type: 'oauth2' 120 | }, 121 | name = 'oauth2' 122 | ): this { 123 | this.addSecurity(name, { 124 | type: 'oauth2', 125 | flows: {}, 126 | ...options 127 | }); 128 | return this; 129 | } 130 | 131 | public addApiKey( 132 | options: SecuritySchemeObject = { 133 | type: 'apiKey' 134 | }, 135 | name = 'api_key' 136 | ): this { 137 | this.addSecurity(name, { 138 | type: 'apiKey', 139 | in: 'header', 140 | name, 141 | ...options 142 | }); 143 | return this; 144 | } 145 | 146 | public addBasicAuth( 147 | options: SecuritySchemeObject = { 148 | type: 'http' 149 | }, 150 | name = 'basic' 151 | ): this { 152 | this.addSecurity(name, { 153 | type: 'http', 154 | scheme: 'basic', 155 | ...options 156 | }); 157 | return this; 158 | } 159 | 160 | public addCookieAuth( 161 | cookieName = 'connect.sid', 162 | options: SecuritySchemeObject = { 163 | type: 'apiKey' 164 | }, 165 | securityName = 'cookie' 166 | ): this { 167 | this.addSecurity(securityName, { 168 | type: 'apiKey', 169 | in: 'cookie', 170 | name: cookieName, 171 | ...options 172 | }); 173 | return this; 174 | } 175 | 176 | public build(): Omit { 177 | return this.document; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /test/plugin/model-class-visitor.spec.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { before } from '../../lib/plugin/compiler-plugin'; 3 | import { 4 | changedCatDtoText, 5 | changedCatDtoTextTranspiled, 6 | originalCatDtoText 7 | } from './fixtures/changed-class.dto'; 8 | import { 9 | createCatDtoAltText, 10 | createCatDtoTextAltTranspiled 11 | } from './fixtures/create-cat-alt.dto'; 12 | import { 13 | createCatDtoAlt2Text, 14 | createCatDtoTextAlt2Transpiled 15 | } from './fixtures/create-cat-alt2.dto'; 16 | import { 17 | createCatDtoText, 18 | createCatDtoTextTranspiled 19 | } from './fixtures/create-cat.dto'; 20 | import { 21 | es5CreateCatDtoText, 22 | es5CreateCatDtoTextTranspiled 23 | } from './fixtures/es5-class.dto'; 24 | import { 25 | nullableDtoText, 26 | nullableDtoTextTranspiled 27 | } from './fixtures/nullable.dto'; 28 | 29 | describe('API model properties', () => { 30 | it('should add the metadata factory when no decorators exist, and generated propertyKey is title', () => { 31 | const options: ts.CompilerOptions = { 32 | module: ts.ModuleKind.ESNext, 33 | target: ts.ScriptTarget.ESNext, 34 | newLine: ts.NewLineKind.LineFeed, 35 | noEmitHelpers: true, 36 | strict: true 37 | }; 38 | const filename = 'create-cat.dto.ts'; 39 | const fakeProgram = ts.createProgram([filename], options); 40 | 41 | const result = ts.transpileModule(createCatDtoText, { 42 | compilerOptions: options, 43 | fileName: filename, 44 | transformers: { 45 | before: [ 46 | before( 47 | { 48 | classValidatorShim: true, 49 | dtoKeyOfComment: 'title', 50 | introspectComments: true 51 | }, 52 | fakeProgram 53 | ) 54 | ] 55 | } 56 | }); 57 | expect(result.outputText).toEqual(createCatDtoTextTranspiled); 58 | }); 59 | 60 | it('should add partial metadata factory when some decorators exist', () => { 61 | const options: ts.CompilerOptions = { 62 | module: ts.ModuleKind.ESNext, 63 | target: ts.ScriptTarget.ESNext, 64 | newLine: ts.NewLineKind.LineFeed, 65 | noEmitHelpers: true, 66 | strict: true 67 | }; 68 | const filename = 'create-cat.dto.ts'; 69 | const fakeProgram = ts.createProgram([filename], options); 70 | 71 | const result = ts.transpileModule(createCatDtoAltText, { 72 | compilerOptions: options, 73 | fileName: filename, 74 | transformers: { 75 | before: [before({ introspectComments: true }, fakeProgram)] 76 | } 77 | }); 78 | expect(result.outputText).toEqual(createCatDtoTextAltTranspiled); 79 | }); 80 | 81 | it('should add partial metadata factory when some decorators exist when exist node without type', () => { 82 | const options: ts.CompilerOptions = { 83 | module: ts.ModuleKind.ESNext, 84 | target: ts.ScriptTarget.ESNext, 85 | newLine: ts.NewLineKind.LineFeed, 86 | noEmitHelpers: true, 87 | strict: true 88 | }; 89 | const filename = 'create-cat-alt2.dto.ts'; 90 | const fakeProgram = ts.createProgram([filename], options); 91 | 92 | const result = ts.transpileModule(createCatDtoAlt2Text, { 93 | compilerOptions: options, 94 | fileName: filename, 95 | transformers: { 96 | before: [ 97 | before( 98 | { introspectComments: true, classValidatorShim: true }, 99 | fakeProgram 100 | ) 101 | ] 102 | } 103 | }); 104 | expect(result.outputText).toEqual(createCatDtoTextAlt2Transpiled); 105 | }); 106 | 107 | it('should manage imports statements when code "downleveled"', () => { 108 | const options: ts.CompilerOptions = { 109 | module: ts.ModuleKind.CommonJS, 110 | target: ts.ScriptTarget.ES5, 111 | newLine: ts.NewLineKind.LineFeed, 112 | noEmitHelpers: true, 113 | strict: true 114 | }; 115 | const filename = 'es5-class.dto.ts'; 116 | const fakeProgram = ts.createProgram([filename], options); 117 | 118 | const result = ts.transpileModule(es5CreateCatDtoText, { 119 | compilerOptions: options, 120 | fileName: filename, 121 | transformers: { 122 | before: [ 123 | before( 124 | { introspectComments: true, classValidatorShim: true }, 125 | fakeProgram 126 | ) 127 | ] 128 | } 129 | }); 130 | expect(result.outputText).toEqual(es5CreateCatDtoTextTranspiled); 131 | }); 132 | 133 | it('should support & understand nullable type unions', () => { 134 | const options: ts.CompilerOptions = { 135 | module: ts.ModuleKind.ESNext, 136 | target: ts.ScriptTarget.ESNext, 137 | newLine: ts.NewLineKind.LineFeed, 138 | noEmitHelpers: true, 139 | strict: true 140 | }; 141 | const filename = 'nullable.dto.ts'; 142 | const fakeProgram = ts.createProgram([filename], options); 143 | 144 | const result = ts.transpileModule(nullableDtoText, { 145 | compilerOptions: options, 146 | fileName: filename, 147 | transformers: { 148 | before: [ 149 | before( 150 | { introspectComments: true, classValidatorShim: true }, 151 | fakeProgram 152 | ) 153 | ] 154 | } 155 | }); 156 | expect(result.outputText).toEqual(nullableDtoTextTranspiled); 157 | }); 158 | 159 | it('should remove properties from metadata when properties removed from dto', () => { 160 | const options: ts.CompilerOptions = { 161 | module: ts.ModuleKind.CommonJS, 162 | target: ts.ScriptTarget.ES5, 163 | newLine: ts.NewLineKind.LineFeed, 164 | noEmitHelpers: true, 165 | strict: true 166 | }; 167 | const filename = 'changed-class.dto.ts'; 168 | const fakeProgram = ts.createProgram([filename], options); 169 | 170 | ts.transpileModule(originalCatDtoText, { 171 | compilerOptions: options, 172 | fileName: filename, 173 | transformers: { 174 | before: [ 175 | before( 176 | { introspectComments: true, classValidatorShim: true }, 177 | fakeProgram 178 | ) 179 | ] 180 | } 181 | }); 182 | 183 | const changedResult = ts.transpileModule(changedCatDtoText, { 184 | compilerOptions: options, 185 | fileName: filename, 186 | transformers: { 187 | before: [ 188 | before( 189 | { introspectComments: true, classValidatorShim: true }, 190 | fakeProgram 191 | ) 192 | ] 193 | } 194 | }); 195 | 196 | expect(changedResult.outputText).toEqual(changedCatDtoTextTranspiled); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /lib/plugin/utils/ast-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallExpression, 3 | CommentRange, 4 | Decorator, 5 | getLeadingCommentRanges, 6 | getTrailingCommentRanges, 7 | Identifier, 8 | LeftHandSideExpression, 9 | Node, 10 | ObjectFlags, 11 | ObjectType, 12 | PropertyAccessExpression, 13 | SourceFile, 14 | SyntaxKind, 15 | Type, 16 | TypeChecker, 17 | TypeFlags, 18 | TypeFormatFlags 19 | } from 'typescript'; 20 | import { isDynamicallyAdded } from './plugin-utils'; 21 | 22 | export function isArray(type: Type) { 23 | const symbol = type.getSymbol(); 24 | if (!symbol) { 25 | return false; 26 | } 27 | return symbol.getName() === 'Array' && getTypeArguments(type).length === 1; 28 | } 29 | 30 | export function getTypeArguments(type: Type) { 31 | return (type as any).typeArguments || []; 32 | } 33 | 34 | export function isBoolean(type: Type) { 35 | return hasFlag(type, TypeFlags.Boolean); 36 | } 37 | 38 | export function isString(type: Type) { 39 | return hasFlag(type, TypeFlags.String); 40 | } 41 | 42 | export function isNumber(type: Type) { 43 | return hasFlag(type, TypeFlags.Number); 44 | } 45 | 46 | export function isInterface(type: Type) { 47 | return hasObjectFlag(type, ObjectFlags.Interface); 48 | } 49 | 50 | export function isEnum(type: Type) { 51 | const hasEnumFlag = hasFlag(type, TypeFlags.Enum); 52 | if (hasEnumFlag) { 53 | return true; 54 | } 55 | if (isEnumLiteral(type)) { 56 | return false; 57 | } 58 | const symbol = type.getSymbol(); 59 | if (!symbol) { 60 | return false; 61 | } 62 | const valueDeclaration = symbol.valueDeclaration; 63 | if (!valueDeclaration) { 64 | return false; 65 | } 66 | return valueDeclaration.kind === SyntaxKind.EnumDeclaration; 67 | } 68 | 69 | export function isEnumLiteral(type: Type) { 70 | return hasFlag(type, TypeFlags.EnumLiteral) && !type.isUnion(); 71 | } 72 | 73 | export function hasFlag(type: Type, flag: TypeFlags) { 74 | return (type.flags & flag) === flag; 75 | } 76 | 77 | export function hasObjectFlag(type: Type, flag: ObjectFlags) { 78 | return ((type as ObjectType).objectFlags & flag) === flag; 79 | } 80 | 81 | export function getText( 82 | type: Type, 83 | typeChecker: TypeChecker, 84 | enclosingNode?: Node, 85 | typeFormatFlags?: TypeFormatFlags 86 | ) { 87 | if (!typeFormatFlags) { 88 | typeFormatFlags = getDefaultTypeFormatFlags(enclosingNode); 89 | } 90 | const compilerNode = !enclosingNode ? undefined : enclosingNode; 91 | return typeChecker.typeToString(type, compilerNode, typeFormatFlags); 92 | } 93 | 94 | export function getDefaultTypeFormatFlags(enclosingNode: Node) { 95 | let formatFlags = 96 | TypeFormatFlags.UseTypeOfFunction | 97 | TypeFormatFlags.NoTruncation | 98 | TypeFormatFlags.UseFullyQualifiedType | 99 | TypeFormatFlags.WriteTypeArgumentsOfSignature; 100 | if (enclosingNode && enclosingNode.kind === SyntaxKind.TypeAliasDeclaration) 101 | formatFlags |= TypeFormatFlags.InTypeAlias; 102 | return formatFlags; 103 | } 104 | 105 | export function getMainCommentAndExamplesOfNode( 106 | node: Node, 107 | sourceFile: SourceFile, 108 | typeChecker: TypeChecker, 109 | includeExamples?: boolean 110 | ): [string, string[]] { 111 | const sourceText = sourceFile.getFullText(); 112 | // in case we decide to include "// comments" 113 | const replaceRegex = /^ *\** *@.*$|^ *\/\*+ *|^ *\/\/+.*|^ *\/+ *|^ *\*+ *| +$| *\**\/ *$/gim; 114 | //const replaceRegex = /^ *\** *@.*$|^ *\/\*+ *|^ *\/+ *|^ *\*+ *| +$| *\**\/ *$/gim; 115 | 116 | const commentResult = []; 117 | const examplesResult = []; 118 | const introspectCommentsAndExamples = (comments?: CommentRange[]) => 119 | comments?.forEach((comment) => { 120 | const commentSource = sourceText.substring(comment.pos, comment.end); 121 | const oneComment = commentSource.replace(replaceRegex, '').trim(); 122 | if (oneComment) { 123 | commentResult.push(oneComment); 124 | } 125 | if (includeExamples) { 126 | const regexOfExample = /@example *((['"](?.+?)['"])|(?[^ ]+?)|(?(\[.+?\]))) *$/gim; 127 | let execResult: RegExpExecArray; 128 | while ( 129 | (execResult = regexOfExample.exec(commentSource)) && 130 | execResult.length > 1 131 | ) { 132 | const example = 133 | execResult.groups?.exampleAsString ?? 134 | execResult.groups?.exampleAsBooleanOrNumber ?? 135 | (execResult.groups?.exampleAsArray && 136 | execResult.groups.exampleAsArray.replace(/'/g, '"')); 137 | 138 | const type = typeChecker.getTypeAtLocation(node); 139 | if (type && isString(type)) { 140 | examplesResult.push(example); 141 | } else { 142 | try { 143 | examplesResult.push(JSON.parse(example)); 144 | } catch { 145 | examplesResult.push(example); 146 | } 147 | } 148 | } 149 | } 150 | }); 151 | 152 | const leadingCommentRanges = getLeadingCommentRanges( 153 | sourceText, 154 | node.getFullStart() 155 | ); 156 | introspectCommentsAndExamples(leadingCommentRanges); 157 | if (!commentResult.length) { 158 | const trailingCommentRanges = getTrailingCommentRanges( 159 | sourceText, 160 | node.getFullStart() 161 | ); 162 | introspectCommentsAndExamples(trailingCommentRanges); 163 | } 164 | return [commentResult.join('\n'), examplesResult]; 165 | } 166 | 167 | export function getDecoratorArguments(decorator: Decorator) { 168 | const callExpression = decorator.expression; 169 | return (callExpression && (callExpression as CallExpression).arguments) || []; 170 | } 171 | 172 | export function getDecoratorName(decorator: Decorator) { 173 | const isDecoratorFactory = 174 | decorator.expression.kind === SyntaxKind.CallExpression; 175 | if (isDecoratorFactory) { 176 | const callExpression = decorator.expression; 177 | const identifier = (callExpression as CallExpression) 178 | .expression as Identifier; 179 | if (isDynamicallyAdded(identifier)) { 180 | return undefined; 181 | } 182 | return getIdentifierFromName( 183 | (callExpression as CallExpression).expression 184 | ).getText(); 185 | } 186 | return getIdentifierFromName(decorator.expression).getText(); 187 | } 188 | 189 | function getIdentifierFromName(expression: LeftHandSideExpression) { 190 | const identifier = getNameFromExpression(expression); 191 | if (expression && expression.kind !== SyntaxKind.Identifier) { 192 | throw new Error(); 193 | } 194 | return identifier; 195 | } 196 | 197 | function getNameFromExpression(expression: LeftHandSideExpression) { 198 | if (expression && expression.kind === SyntaxKind.PropertyAccessExpression) { 199 | return (expression as PropertyAccessExpression).name; 200 | } 201 | return expression; 202 | } 203 | -------------------------------------------------------------------------------- /lib/decorators/api-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | import { HttpStatus } from '@nestjs/common/enums/http-status.enum'; 3 | import { omit } from 'lodash'; 4 | import { DECORATORS } from '../constants'; 5 | import { 6 | ResponseObject, 7 | SchemaObject, 8 | ReferenceObject 9 | } from '../interfaces/open-api-spec.interface'; 10 | import { getTypeIsArrayTuple } from './helpers'; 11 | 12 | export interface ApiResponseMetadata 13 | extends Omit { 14 | status?: number | 'default'; 15 | type?: Type | Function | [Function] | string; 16 | isArray?: boolean; 17 | description?: string; 18 | } 19 | 20 | export interface ApiResponseSchemaHost 21 | extends Omit { 22 | schema: SchemaObject & Partial; 23 | status?: number; 24 | description?: string; 25 | } 26 | 27 | export type ApiResponseOptions = ApiResponseMetadata | ApiResponseSchemaHost; 28 | 29 | export function ApiResponse( 30 | options: ApiResponseOptions 31 | ): MethodDecorator & ClassDecorator { 32 | const [type, isArray] = getTypeIsArrayTuple( 33 | (options as ApiResponseMetadata).type, 34 | (options as ApiResponseMetadata).isArray 35 | ); 36 | 37 | (options as ApiResponseMetadata).type = type; 38 | (options as ApiResponseMetadata).isArray = isArray; 39 | options.description = options.description ? options.description : ''; 40 | 41 | const groupedMetadata = { [options.status]: omit(options, 'status') }; 42 | return ( 43 | target: object, 44 | key?: string | symbol, 45 | descriptor?: TypedPropertyDescriptor 46 | ): any => { 47 | if (descriptor) { 48 | const responses = 49 | Reflect.getMetadata(DECORATORS.API_RESPONSE, descriptor.value) || {}; 50 | Reflect.defineMetadata( 51 | DECORATORS.API_RESPONSE, 52 | { 53 | ...responses, 54 | ...groupedMetadata 55 | }, 56 | descriptor.value 57 | ); 58 | return descriptor; 59 | } 60 | const responses = 61 | Reflect.getMetadata(DECORATORS.API_RESPONSE, target) || {}; 62 | Reflect.defineMetadata( 63 | DECORATORS.API_RESPONSE, 64 | { 65 | ...responses, 66 | ...groupedMetadata 67 | }, 68 | target 69 | ); 70 | return target; 71 | }; 72 | } 73 | 74 | export const ApiOkResponse = (options: ApiResponseOptions = {}) => 75 | ApiResponse({ 76 | ...options, 77 | status: HttpStatus.OK 78 | }); 79 | 80 | export const ApiCreatedResponse = (options: ApiResponseOptions = {}) => 81 | ApiResponse({ 82 | ...options, 83 | status: HttpStatus.CREATED 84 | }); 85 | 86 | export const ApiAcceptedResponse = (options: ApiResponseOptions = {}) => 87 | ApiResponse({ 88 | ...options, 89 | status: HttpStatus.ACCEPTED 90 | }); 91 | 92 | export const ApiNoContentResponse = (options: ApiResponseOptions = {}) => 93 | ApiResponse({ 94 | ...options, 95 | status: HttpStatus.NO_CONTENT 96 | }); 97 | 98 | export const ApiMovedPermanentlyResponse = (options: ApiResponseOptions = {}) => 99 | ApiResponse({ 100 | ...options, 101 | status: HttpStatus.MOVED_PERMANENTLY 102 | }); 103 | 104 | export const ApiFoundResponse = (options: ApiResponseOptions = {}) => 105 | ApiResponse({ 106 | ...options, 107 | status: HttpStatus.FOUND 108 | }); 109 | 110 | export const ApiBadRequestResponse = (options: ApiResponseOptions = {}) => 111 | ApiResponse({ 112 | ...options, 113 | status: HttpStatus.BAD_REQUEST 114 | }); 115 | 116 | export const ApiUnauthorizedResponse = (options: ApiResponseOptions = {}) => 117 | ApiResponse({ 118 | ...options, 119 | status: HttpStatus.UNAUTHORIZED 120 | }); 121 | 122 | export const ApiTooManyRequestsResponse = (options: ApiResponseOptions = {}) => 123 | ApiResponse({ 124 | ...options, 125 | status: HttpStatus.TOO_MANY_REQUESTS 126 | }); 127 | 128 | export const ApiNotFoundResponse = (options: ApiResponseOptions = {}) => 129 | ApiResponse({ 130 | ...options, 131 | status: HttpStatus.NOT_FOUND 132 | }); 133 | 134 | export const ApiInternalServerErrorResponse = ( 135 | options: ApiResponseOptions = {} 136 | ) => 137 | ApiResponse({ 138 | ...options, 139 | status: HttpStatus.INTERNAL_SERVER_ERROR 140 | }); 141 | 142 | export const ApiBadGatewayResponse = (options: ApiResponseOptions = {}) => 143 | ApiResponse({ 144 | ...options, 145 | status: HttpStatus.BAD_GATEWAY 146 | }); 147 | 148 | export const ApiConflictResponse = (options: ApiResponseOptions = {}) => 149 | ApiResponse({ 150 | ...options, 151 | status: HttpStatus.CONFLICT 152 | }); 153 | 154 | export const ApiForbiddenResponse = (options: ApiResponseOptions = {}) => 155 | ApiResponse({ 156 | ...options, 157 | status: HttpStatus.FORBIDDEN 158 | }); 159 | 160 | export const ApiGatewayTimeoutResponse = (options: ApiResponseOptions = {}) => 161 | ApiResponse({ 162 | ...options, 163 | status: HttpStatus.GATEWAY_TIMEOUT 164 | }); 165 | 166 | export const ApiGoneResponse = (options: ApiResponseOptions = {}) => 167 | ApiResponse({ 168 | ...options, 169 | status: HttpStatus.GONE 170 | }); 171 | 172 | export const ApiMethodNotAllowedResponse = (options: ApiResponseOptions = {}) => 173 | ApiResponse({ 174 | ...options, 175 | status: HttpStatus.METHOD_NOT_ALLOWED 176 | }); 177 | 178 | export const ApiNotAcceptableResponse = (options: ApiResponseOptions = {}) => 179 | ApiResponse({ 180 | ...options, 181 | status: HttpStatus.NOT_ACCEPTABLE 182 | }); 183 | 184 | export const ApiNotImplementedResponse = (options: ApiResponseOptions = {}) => 185 | ApiResponse({ 186 | ...options, 187 | status: HttpStatus.NOT_IMPLEMENTED 188 | }); 189 | 190 | export const ApiPreconditionFailedResponse = ( 191 | options: ApiResponseOptions = {} 192 | ) => 193 | ApiResponse({ 194 | ...options, 195 | status: HttpStatus.PRECONDITION_FAILED 196 | }); 197 | 198 | export const ApiPayloadTooLargeResponse = (options: ApiResponseOptions = {}) => 199 | ApiResponse({ 200 | ...options, 201 | status: HttpStatus.PAYLOAD_TOO_LARGE 202 | }); 203 | 204 | export const ApiRequestTimeoutResponse = (options: ApiResponseOptions = {}) => 205 | ApiResponse({ 206 | ...options, 207 | status: HttpStatus.REQUEST_TIMEOUT 208 | }); 209 | 210 | export const ApiServiceUnavailableResponse = ( 211 | options: ApiResponseOptions = {} 212 | ) => 213 | ApiResponse({ 214 | ...options, 215 | status: HttpStatus.SERVICE_UNAVAILABLE 216 | }); 217 | 218 | export const ApiUnprocessableEntityResponse = ( 219 | options: ApiResponseOptions = {} 220 | ) => 221 | ApiResponse({ 222 | ...options, 223 | status: HttpStatus.UNPROCESSABLE_ENTITY 224 | }); 225 | 226 | export const ApiUnsupportedMediaTypeResponse = ( 227 | options: ApiResponseOptions = {} 228 | ) => 229 | ApiResponse({ 230 | ...options, 231 | status: HttpStatus.UNSUPPORTED_MEDIA_TYPE 232 | }); 233 | 234 | export const ApiDefaultResponse = (options: ApiResponseOptions = {}) => 235 | ApiResponse({ 236 | ...options, 237 | status: 'default' 238 | }); 239 | -------------------------------------------------------------------------------- /test/services/schema-object-factory.spec.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '../../lib/decorators'; 2 | import { ModelPropertiesAccessor } from '../../lib/services/model-properties-accessor'; 3 | import { SchemaObjectFactory } from '../../lib/services/schema-object-factory'; 4 | import { SwaggerTypesMapper } from '../../lib/services/swagger-types-mapper'; 5 | import { CreateUserDto } from './fixtures/create-user.dto'; 6 | 7 | describe('SchemaObjectFactory', () => { 8 | let modelPropertiesAccessor: ModelPropertiesAccessor; 9 | let swaggerTypesMapper: SwaggerTypesMapper; 10 | let schemaObjectFactory: SchemaObjectFactory; 11 | 12 | beforeEach(() => { 13 | modelPropertiesAccessor = new ModelPropertiesAccessor(); 14 | swaggerTypesMapper = new SwaggerTypesMapper(); 15 | schemaObjectFactory = new SchemaObjectFactory( 16 | modelPropertiesAccessor, 17 | swaggerTypesMapper 18 | ); 19 | }); 20 | 21 | describe('exploreModelSchema', () => { 22 | enum Role { 23 | Admin = 'admin', 24 | User = 'user' 25 | } 26 | 27 | class CreatePersonDto { 28 | @ApiProperty() 29 | name: string; 30 | @ApiProperty({ enum: Role, enumName: 'Role' }) 31 | role: Role; 32 | } 33 | 34 | class Person { 35 | @ApiProperty({ enum: Role, enumName: 'Role' }) 36 | role: Role; 37 | @ApiProperty({ enum: Role, enumName: 'Role', isArray: true }) 38 | roles: Role[]; 39 | } 40 | 41 | it('should explore enum', () => { 42 | const schemas = []; 43 | schemaObjectFactory.exploreModelSchema(Person, schemas); 44 | 45 | expect(schemas).toHaveLength(2); 46 | 47 | expect(schemas[0]['Role']).toBeDefined(); 48 | expect(schemas[0]['Role']).toEqual({ 49 | type: 'string', 50 | enum: ['admin', 'user'] 51 | }); 52 | expect(schemas[1]['Person']).toBeDefined(); 53 | expect(schemas[1]['Person']).toEqual({ 54 | type: 'object', 55 | properties: { 56 | role: { 57 | $ref: '#/components/schemas/Role' 58 | }, 59 | roles: { 60 | type: 'array', 61 | items: { 62 | $ref: '#/components/schemas/Role' 63 | } 64 | } 65 | }, 66 | required: ['role', 'roles'] 67 | }); 68 | 69 | schemaObjectFactory.exploreModelSchema(CreatePersonDto, schemas, [ 70 | 'Person', 71 | 'Role' 72 | ]); 73 | 74 | expect(schemas).toHaveLength(3); 75 | expect(schemas[2]['CreatePersonDto']).toBeDefined(); 76 | expect(schemas[2]['CreatePersonDto']).toEqual({ 77 | type: 'object', 78 | properties: { 79 | name: { 80 | type: 'string' 81 | }, 82 | role: { 83 | $ref: '#/components/schemas/Role' 84 | } 85 | }, 86 | required: ['name', 'role'] 87 | }); 88 | }); 89 | 90 | it('should create openapi schema', () => { 91 | const schemas = []; 92 | const schemaKey = schemaObjectFactory.exploreModelSchema( 93 | CreateUserDto, 94 | schemas 95 | ); 96 | 97 | expect(schemas[1][schemaKey]).toEqual({ 98 | type: 'object', 99 | properties: { 100 | login: { 101 | type: 'string' 102 | }, 103 | password: { 104 | type: 'string', 105 | examples: ['test', 'test2'] 106 | }, 107 | houses: { 108 | items: { 109 | $ref: '#/components/schemas/House' 110 | }, 111 | type: 'array' 112 | }, 113 | age: { 114 | type: 'number', 115 | format: 'int64', 116 | example: 10 117 | }, 118 | createdAt: { 119 | format: 'date-time', 120 | type: 'string' 121 | }, 122 | custom: { 123 | readOnly: true, 124 | type: 'array', 125 | maxItems: 10, 126 | minItems: 1, 127 | items: { 128 | type: 'array', 129 | items: { 130 | type: 'number' 131 | } 132 | } 133 | }, 134 | profile: { 135 | description: 'Profile', 136 | nullable: true, 137 | allOf: [ 138 | { 139 | $ref: '#/components/schemas/CreateProfileDto' 140 | } 141 | ] 142 | }, 143 | tags: { 144 | items: { 145 | type: 'string' 146 | }, 147 | type: 'array' 148 | }, 149 | urls: { 150 | items: { 151 | type: 'string' 152 | }, 153 | type: 'array' 154 | }, 155 | luckyNumbers: { 156 | type: 'array', 157 | items: { 158 | type: 'integer' 159 | } 160 | }, 161 | options: { 162 | items: { 163 | properties: { 164 | isReadonly: { 165 | type: 'string' 166 | } 167 | }, 168 | type: 'object' 169 | }, 170 | type: 'array' 171 | }, 172 | allOf: { 173 | oneOf: [ 174 | { $ref: '#/components/schemas/Cat' }, 175 | { $ref: '#/components/schemas/Dog' } 176 | ], 177 | discriminator: { propertyName: 'pet_type' } 178 | } 179 | }, 180 | required: [ 181 | 'login', 182 | 'password', 183 | 'profile', 184 | 'tags', 185 | 'urls', 186 | 'luckyNumbers', 187 | 'options', 188 | 'allOf', 189 | 'houses', 190 | 'createdAt' 191 | ] 192 | }); 193 | expect(schemas[2]['CreateProfileDto']).toEqual({ 194 | type: 'object', 195 | properties: { 196 | firstname: { 197 | type: 'string' 198 | }, 199 | lastname: { 200 | type: 'string' 201 | }, 202 | parent: { 203 | $ref: '#/components/schemas/CreateUserDto' 204 | } 205 | }, 206 | required: ['firstname', 'lastname', 'parent'] 207 | }); 208 | }); 209 | 210 | it('should override base class metadata', () => { 211 | class CreatUserDto { 212 | @ApiProperty({ minLength: 0, required: true }) 213 | name: string; 214 | } 215 | 216 | class UpdateUserDto extends CreatUserDto { 217 | @ApiProperty({ minLength: 1, required: false }) 218 | name: string; 219 | } 220 | 221 | const schemas = []; 222 | 223 | schemaObjectFactory.exploreModelSchema(CreatUserDto, schemas); 224 | schemaObjectFactory.exploreModelSchema(UpdateUserDto, schemas); 225 | 226 | expect(schemas[0][CreatUserDto.name]).toEqual({ 227 | type: 'object', 228 | properties: { name: { type: 'string', minLength: 0 } }, 229 | required: ['name'] 230 | }); 231 | 232 | expect(schemas[1][UpdateUserDto.name]).toEqual({ 233 | type: 'object', 234 | properties: { name: { type: 'string', minLength: 1 } } 235 | }); 236 | }); 237 | }); 238 | }); 239 | -------------------------------------------------------------------------------- /lib/plugin/visitors/controller-class.visitor.ts: -------------------------------------------------------------------------------- 1 | import { compact, head } from 'lodash'; 2 | import * as ts from 'typescript'; 3 | import { ApiOperation, ApiResponse } from '../../decorators'; 4 | import { PluginOptions } from '../merge-options'; 5 | import { OPENAPI_NAMESPACE } from '../plugin-constants'; 6 | import { 7 | getDecoratorArguments, 8 | getMainCommentAndExamplesOfNode 9 | } from '../utils/ast-utils'; 10 | import { 11 | getDecoratorOrUndefinedByNames, 12 | getTypeReferenceAsString, 13 | hasPropertyKey, 14 | replaceImportPath 15 | } from '../utils/plugin-utils'; 16 | import { AbstractFileVisitor } from './abstract.visitor'; 17 | 18 | export class ControllerClassVisitor extends AbstractFileVisitor { 19 | visit( 20 | sourceFile: ts.SourceFile, 21 | ctx: ts.TransformationContext, 22 | program: ts.Program, 23 | options: PluginOptions 24 | ) { 25 | const typeChecker = program.getTypeChecker(); 26 | sourceFile = this.updateImports(sourceFile, ctx.factory); 27 | 28 | const visitNode = (node: ts.Node): ts.Node => { 29 | if (ts.isMethodDeclaration(node)) { 30 | try { 31 | return this.addDecoratorToNode( 32 | node, 33 | typeChecker, 34 | options, 35 | sourceFile.fileName, 36 | sourceFile 37 | ); 38 | } catch { 39 | return node; 40 | } 41 | } 42 | return ts.visitEachChild(node, visitNode, ctx); 43 | }; 44 | return ts.visitNode(sourceFile, visitNode); 45 | } 46 | 47 | addDecoratorToNode( 48 | compilerNode: ts.MethodDeclaration, 49 | typeChecker: ts.TypeChecker, 50 | options: PluginOptions, 51 | hostFilename: string, 52 | sourceFile: ts.SourceFile 53 | ): ts.MethodDeclaration { 54 | const node = ts.getMutableClone(compilerNode); 55 | if (!node.decorators) { 56 | return node; 57 | } 58 | const nodeArray = node.decorators; 59 | const { pos, end } = nodeArray; 60 | 61 | (node as any).decorators = Object.assign( 62 | [ 63 | ...this.createApiOperationDecorator( 64 | node, 65 | nodeArray, 66 | options, 67 | sourceFile, 68 | typeChecker 69 | ), 70 | ...nodeArray, 71 | ts.createDecorator( 72 | ts.createCall( 73 | ts.createIdentifier(`${OPENAPI_NAMESPACE}.${ApiResponse.name}`), 74 | undefined, 75 | [ 76 | this.createDecoratorObjectLiteralExpr( 77 | node, 78 | typeChecker, 79 | ts.createNodeArray(), 80 | hostFilename 81 | ) 82 | ] 83 | ) 84 | ) 85 | ], 86 | { pos, end } 87 | ); 88 | return node; 89 | } 90 | 91 | createApiOperationDecorator( 92 | node: ts.MethodDeclaration, 93 | nodeArray: ts.NodeArray, 94 | options: PluginOptions, 95 | sourceFile: ts.SourceFile, 96 | typeChecker: ts.TypeChecker 97 | ) { 98 | if (!options.introspectComments) { 99 | return []; 100 | } 101 | const keyToGenerate = options.controllerKeyOfComment; 102 | const apiOperationDecorator = getDecoratorOrUndefinedByNames( 103 | [ApiOperation.name], 104 | nodeArray 105 | ); 106 | const apiOperationExpr: ts.ObjectLiteralExpression | undefined = 107 | apiOperationDecorator && 108 | head(getDecoratorArguments(apiOperationDecorator)); 109 | const apiOperationExprProperties = 110 | apiOperationExpr && 111 | (apiOperationExpr.properties as ts.NodeArray); 112 | 113 | if ( 114 | !apiOperationDecorator || 115 | !apiOperationExpr || 116 | !apiOperationExprProperties || 117 | !hasPropertyKey(keyToGenerate, apiOperationExprProperties) 118 | ) { 119 | const [extractedComments] = getMainCommentAndExamplesOfNode( 120 | node, 121 | sourceFile, 122 | typeChecker 123 | ); 124 | if (!extractedComments) { 125 | // Node does not have any comments 126 | return []; 127 | } 128 | const properties = [ 129 | ts.createPropertyAssignment( 130 | keyToGenerate, 131 | ts.createLiteral(extractedComments) 132 | ), 133 | ...(apiOperationExprProperties ?? ts.createNodeArray()) 134 | ]; 135 | const apiOperationDecoratorArguments: ts.NodeArray = ts.createNodeArray( 136 | [ts.createObjectLiteral(compact(properties))] 137 | ); 138 | if (apiOperationDecorator) { 139 | ((apiOperationDecorator.expression as ts.CallExpression) as any).arguments = apiOperationDecoratorArguments; 140 | } else { 141 | return [ 142 | ts.createDecorator( 143 | ts.createCall( 144 | ts.createIdentifier(`${OPENAPI_NAMESPACE}.${ApiOperation.name}`), 145 | undefined, 146 | apiOperationDecoratorArguments 147 | ) 148 | ) 149 | ]; 150 | } 151 | } 152 | return []; 153 | } 154 | 155 | createDecoratorObjectLiteralExpr( 156 | node: ts.MethodDeclaration, 157 | typeChecker: ts.TypeChecker, 158 | existingProperties: ts.NodeArray = ts.createNodeArray(), 159 | hostFilename: string 160 | ): ts.ObjectLiteralExpression { 161 | const properties = [ 162 | ...existingProperties, 163 | this.createStatusPropertyAssignment(node, existingProperties), 164 | this.createTypePropertyAssignment( 165 | node, 166 | typeChecker, 167 | existingProperties, 168 | hostFilename 169 | ) 170 | ]; 171 | return ts.createObjectLiteral(compact(properties)); 172 | } 173 | 174 | createTypePropertyAssignment( 175 | node: ts.MethodDeclaration, 176 | typeChecker: ts.TypeChecker, 177 | existingProperties: ts.NodeArray, 178 | hostFilename: string 179 | ) { 180 | if (hasPropertyKey('type', existingProperties)) { 181 | return undefined; 182 | } 183 | const signature = typeChecker.getSignatureFromDeclaration(node); 184 | const type = typeChecker.getReturnTypeOfSignature(signature); 185 | if (!type) { 186 | return undefined; 187 | } 188 | let typeReference = getTypeReferenceAsString(type, typeChecker); 189 | if (!typeReference) { 190 | return undefined; 191 | } 192 | if (typeReference.includes('node_modules')) { 193 | return undefined; 194 | } 195 | typeReference = replaceImportPath(typeReference, hostFilename); 196 | return ts.createPropertyAssignment( 197 | 'type', 198 | ts.createIdentifier(typeReference) 199 | ); 200 | } 201 | 202 | createStatusPropertyAssignment( 203 | node: ts.MethodDeclaration, 204 | existingProperties: ts.NodeArray 205 | ) { 206 | if (hasPropertyKey('status', existingProperties)) { 207 | return undefined; 208 | } 209 | const statusNode = this.getStatusCodeIdentifier(node); 210 | return ts.createPropertyAssignment('status', statusNode); 211 | } 212 | 213 | getStatusCodeIdentifier(node: ts.MethodDeclaration) { 214 | const decorators = node.decorators; 215 | const httpCodeDecorator = getDecoratorOrUndefinedByNames( 216 | ['HttpCode'], 217 | decorators 218 | ); 219 | if (httpCodeDecorator) { 220 | const argument = head(getDecoratorArguments(httpCodeDecorator)); 221 | if (argument) { 222 | return argument; 223 | } 224 | } 225 | const postDecorator = getDecoratorOrUndefinedByNames(['Post'], decorators); 226 | if (postDecorator) { 227 | return ts.createIdentifier('201'); 228 | } 229 | return ts.createIdentifier('200'); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /lib/plugin/utils/plugin-utils.ts: -------------------------------------------------------------------------------- 1 | import { head } from 'lodash'; 2 | import { posix } from 'path'; 3 | import * as ts from 'typescript'; 4 | import { 5 | getDecoratorName, 6 | getText, 7 | getTypeArguments, 8 | isArray, 9 | isBoolean, 10 | isEnum, 11 | isInterface, 12 | isNumber, 13 | isString 14 | } from './ast-utils'; 15 | 16 | export function getDecoratorOrUndefinedByNames( 17 | names: string[], 18 | decorators: ts.NodeArray 19 | ): ts.Decorator | undefined { 20 | return (decorators || ts.createNodeArray()).find((item) => { 21 | try { 22 | const decoratorName = getDecoratorName(item); 23 | return names.includes(decoratorName); 24 | } catch { 25 | return false; 26 | } 27 | }); 28 | } 29 | 30 | export function getTypeReferenceAsString( 31 | type: ts.Type, 32 | typeChecker: ts.TypeChecker 33 | ): string { 34 | if (isArray(type)) { 35 | const arrayType = getTypeArguments(type)[0]; 36 | const elementType = getTypeReferenceAsString(arrayType, typeChecker); 37 | if (!elementType) { 38 | return undefined; 39 | } 40 | return `[${elementType}]`; 41 | } 42 | if (isBoolean(type)) { 43 | return Boolean.name; 44 | } 45 | if (isNumber(type)) { 46 | return Number.name; 47 | } 48 | if (isString(type)) { 49 | return String.name; 50 | } 51 | if (isPromiseOrObservable(getText(type, typeChecker))) { 52 | const typeArguments = getTypeArguments(type); 53 | const elementType = getTypeReferenceAsString( 54 | head(typeArguments), 55 | typeChecker 56 | ); 57 | if (!elementType) { 58 | return undefined; 59 | } 60 | return elementType; 61 | } 62 | if (type.isClass()) { 63 | return getText(type, typeChecker); 64 | } 65 | try { 66 | const text = getText(type, typeChecker); 67 | if (text === Date.name) { 68 | return text; 69 | } 70 | if (isOptionalBoolean(text)) { 71 | return Boolean.name; 72 | } 73 | if ( 74 | isAutoGeneratedTypeUnion(type) || 75 | isAutoGeneratedEnumUnion(type, typeChecker) 76 | ) { 77 | const types = (type as ts.UnionOrIntersectionType).types; 78 | return getTypeReferenceAsString(types[types.length - 1], typeChecker); 79 | } 80 | if ( 81 | text === 'any' || 82 | text === 'unknown' || 83 | text === 'object' || 84 | isInterface(type) || 85 | (type.isUnionOrIntersection() && !isEnum(type)) 86 | ) { 87 | return 'Object'; 88 | } 89 | if (isEnum(type)) { 90 | return undefined; 91 | } 92 | if (type.aliasSymbol) { 93 | return 'Object'; 94 | } 95 | return undefined; 96 | } catch { 97 | return undefined; 98 | } 99 | } 100 | 101 | export function isPromiseOrObservable(type: string) { 102 | return type.includes('Promise') || type.includes('Observable'); 103 | } 104 | 105 | export function hasPropertyKey( 106 | key: string, 107 | properties: ts.NodeArray 108 | ): boolean { 109 | return properties 110 | .filter((item) => !isDynamicallyAdded(item)) 111 | .some((item) => item.name.getText() === key); 112 | } 113 | 114 | export function replaceImportPath(typeReference: string, fileName: string) { 115 | if (!typeReference.includes('import')) { 116 | return typeReference; 117 | } 118 | let importPath = /\(\"([^)]).+(\")/.exec(typeReference)[0]; 119 | if (!importPath) { 120 | return undefined; 121 | } 122 | importPath = convertPath(importPath); 123 | importPath = importPath.slice(2, importPath.length - 1); 124 | 125 | let relativePath = posix.relative(posix.dirname(fileName), importPath); 126 | relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath; 127 | 128 | const nodeModulesText = 'node_modules'; 129 | const nodeModulePos = relativePath.indexOf(nodeModulesText); 130 | if (nodeModulePos >= 0) { 131 | relativePath = relativePath.slice( 132 | nodeModulePos + nodeModulesText.length + 1 // slash 133 | ); 134 | 135 | const typesText = '@types'; 136 | const typesPos = relativePath.indexOf(typesText); 137 | if (typesPos >= 0) { 138 | relativePath = relativePath.slice( 139 | typesPos + typesText.length + 1 //slash 140 | ); 141 | } 142 | 143 | const indexText = '/index'; 144 | const indexPos = relativePath.indexOf(indexText); 145 | if (indexPos >= 0) { 146 | relativePath = relativePath.slice(0, indexPos); 147 | } 148 | } 149 | 150 | typeReference = typeReference.replace(importPath, relativePath); 151 | return typeReference.replace('import', 'require'); 152 | } 153 | 154 | export function isDynamicallyAdded(identifier: ts.Node) { 155 | return identifier && !identifier.parent && identifier.pos === -1; 156 | } 157 | 158 | /** 159 | * when "strict" mode enabled, TypeScript transform the enum type to a union composed of 160 | * the enum values and the undefined type. Hence, we have to lookup all the union types to get the original type 161 | * @param type 162 | * @param typeChecker 163 | */ 164 | export function isAutoGeneratedEnumUnion( 165 | type: ts.Type, 166 | typeChecker: ts.TypeChecker 167 | ): ts.Type { 168 | if (type.isUnionOrIntersection() && !isEnum(type)) { 169 | if (!type.types) { 170 | return undefined; 171 | } 172 | const undefinedTypeIndex = type.types.findIndex( 173 | (type: any) => type.intrinsicName === 'undefined' 174 | ); 175 | if (undefinedTypeIndex < 0) { 176 | return undefined; 177 | } 178 | 179 | // "strict" mode for enums 180 | let parentType = undefined; 181 | const isParentSymbolEqual = type.types.every((item, index) => { 182 | if (index === undefinedTypeIndex) { 183 | return true; 184 | } 185 | if (!item.symbol) { 186 | return false; 187 | } 188 | if ( 189 | !(item.symbol as any).parent || 190 | item.symbol.flags !== ts.SymbolFlags.EnumMember 191 | ) { 192 | return false; 193 | } 194 | const symbolType = typeChecker.getDeclaredTypeOfSymbol( 195 | (item.symbol as any).parent 196 | ); 197 | if (symbolType === parentType || !parentType) { 198 | parentType = symbolType; 199 | return true; 200 | } 201 | return false; 202 | }); 203 | if (isParentSymbolEqual) { 204 | return parentType; 205 | } 206 | } 207 | return undefined; 208 | } 209 | 210 | /** 211 | * when "strict" mode enabled, TypeScript transform the type signature of optional properties to 212 | * the {undefined | T} where T is the original type. Hence, we have to extract the last type of type union 213 | * @param type 214 | */ 215 | export function isAutoGeneratedTypeUnion(type: ts.Type): boolean { 216 | if (type.isUnionOrIntersection() && !isEnum(type)) { 217 | if (!type.types) { 218 | return false; 219 | } 220 | const undefinedTypeIndex = type.types.findIndex( 221 | (type: any) => type.intrinsicName === 'undefined' 222 | ); 223 | 224 | // "strict" mode for non-enum properties 225 | if (type.types.length === 2 && undefinedTypeIndex >= 0) { 226 | return true; 227 | } 228 | } 229 | return false; 230 | } 231 | 232 | export function extractTypeArgumentIfArray(type: ts.Type) { 233 | if (isArray(type)) { 234 | type = getTypeArguments(type)[0]; 235 | if (!type) { 236 | return undefined; 237 | } 238 | return { 239 | type, 240 | isArray: true 241 | }; 242 | } 243 | return { 244 | type, 245 | isArray: false 246 | }; 247 | } 248 | 249 | /** 250 | * when "strict" mode enabled, TypeScript transform optional boolean properties to "boolean | undefined" 251 | * @param text 252 | */ 253 | function isOptionalBoolean(text: string) { 254 | return typeof text === 'string' && text === 'boolean | undefined'; 255 | } 256 | 257 | /** 258 | * Converts Windows specific file paths to posix 259 | * @param windowsPath 260 | */ 261 | function convertPath(windowsPath: string) { 262 | return windowsPath 263 | .replace(/^\\\\\?\\/, '') 264 | .replace(/\\/g, '/') 265 | .replace(/\/\/+/g, '/'); 266 | } 267 | -------------------------------------------------------------------------------- /lib/interfaces/open-api-spec.interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * inspired by https://github.com/metadevpro/openapi3-ts 3 | * @see https://github.com/OAI/OpenAPI-Specification/blob/3.0.0-rc0/versions/3.0.md 4 | */ 5 | 6 | export interface OpenAPIObject { 7 | openapi: string; 8 | info: InfoObject; 9 | servers?: ServerObject[]; 10 | paths: PathsObject; 11 | components?: ComponentsObject; 12 | security?: SecurityRequirementObject[]; 13 | tags?: TagObject[]; 14 | externalDocs?: ExternalDocumentationObject; 15 | } 16 | 17 | export interface InfoObject { 18 | title: string; 19 | description?: string; 20 | termsOfService?: string; 21 | contact?: ContactObject; 22 | license?: LicenseObject; 23 | version: string; 24 | } 25 | 26 | export interface ContactObject { 27 | name?: string; 28 | url?: string; 29 | email?: string; 30 | } 31 | 32 | export interface LicenseObject { 33 | name: string; 34 | url?: string; 35 | } 36 | 37 | export interface ServerObject { 38 | url: string; 39 | description?: string; 40 | variables?: Record; 41 | } 42 | 43 | export interface ServerVariableObject { 44 | enum?: string[] | boolean[] | number[]; 45 | default: string | boolean | number; 46 | description?: string; 47 | } 48 | 49 | export interface ComponentsObject { 50 | schemas?: Record; 51 | responses?: Record; 52 | parameters?: Record; 53 | examples?: Record; 54 | requestBodies?: Record; 55 | headers?: Record; 56 | securitySchemes?: Record; 57 | links?: Record; 58 | callbacks?: Record; 59 | } 60 | 61 | export type PathsObject = Record; 62 | export interface PathItemObject { 63 | $ref?: string; 64 | summary?: string; 65 | description?: string; 66 | get?: OperationObject; 67 | put?: OperationObject; 68 | post?: OperationObject; 69 | delete?: OperationObject; 70 | options?: OperationObject; 71 | head?: OperationObject; 72 | patch?: OperationObject; 73 | trace?: OperationObject; 74 | servers?: ServerObject[]; 75 | parameters?: (ParameterObject | ReferenceObject)[]; 76 | } 77 | 78 | export interface OperationObject { 79 | tags?: string[]; 80 | summary?: string; 81 | description?: string; 82 | externalDocs?: ExternalDocumentationObject; 83 | operationId?: string; 84 | parameters?: (ParameterObject | ReferenceObject)[]; 85 | requestBody?: RequestBodyObject | ReferenceObject; 86 | responses: ResponsesObject; 87 | callbacks?: CallbacksObject; 88 | deprecated?: boolean; 89 | security?: SecurityRequirementObject[]; 90 | servers?: ServerObject[]; 91 | } 92 | 93 | export interface ExternalDocumentationObject { 94 | description?: string; 95 | url: string; 96 | } 97 | 98 | export type ParameterLocation = 'query' | 'header' | 'path' | 'cookie'; 99 | export type ParameterStyle = 100 | | 'matrix' 101 | | 'label' 102 | | 'form' 103 | | 'simple' 104 | | 'spaceDelimited' 105 | | 'pipeDelimited' 106 | | 'deepObject'; 107 | 108 | export interface BaseParameterObject { 109 | description?: string; 110 | required?: boolean; 111 | deprecated?: boolean; 112 | allowEmptyValue?: boolean; 113 | style?: ParameterStyle; 114 | explode?: boolean; 115 | allowReserved?: boolean; 116 | schema?: SchemaObject | ReferenceObject; 117 | examples?: Record; 118 | example?: any; 119 | content?: ContentObject; 120 | } 121 | 122 | export interface ParameterObject extends BaseParameterObject { 123 | name: string; 124 | in: ParameterLocation; 125 | } 126 | 127 | export interface RequestBodyObject { 128 | description?: string; 129 | content: ContentObject; 130 | required?: boolean; 131 | } 132 | 133 | export type ContentObject = Record; 134 | export interface MediaTypeObject { 135 | schema?: SchemaObject | ReferenceObject; 136 | examples?: ExamplesObject; 137 | example?: any; 138 | encoding?: EncodingObject; 139 | } 140 | 141 | export type EncodingObject = Record; 142 | export interface EncodingPropertyObject { 143 | contentType?: string; 144 | headers?: Record; 145 | style?: string; 146 | explode?: boolean; 147 | allowReserved?: boolean; 148 | } 149 | 150 | export interface ResponsesObject 151 | extends Record { 152 | default?: ResponseObject | ReferenceObject; 153 | } 154 | 155 | export interface ResponseObject { 156 | description: string; 157 | headers?: HeadersObject; 158 | content?: ContentObject; 159 | links?: LinksObject; 160 | } 161 | 162 | export type CallbacksObject = Record; 163 | export type CallbackObject = Record; 164 | export type HeadersObject = Record; 165 | 166 | export interface ExampleObject { 167 | summary?: string; 168 | description?: string; 169 | value?: any; 170 | externalValue?: string; 171 | } 172 | 173 | export type LinksObject = Record; 174 | export interface LinkObject { 175 | operationRef?: string; 176 | operationId?: string; 177 | parameters?: LinkParametersObject; 178 | requestBody?: any | string; 179 | description?: string; 180 | server?: ServerObject; 181 | } 182 | 183 | export type LinkParametersObject = Record; 184 | export type HeaderObject = BaseParameterObject; 185 | export interface TagObject { 186 | name: string; 187 | description?: string; 188 | externalDocs?: ExternalDocumentationObject; 189 | } 190 | 191 | export type ExamplesObject = Record; 192 | 193 | export interface ReferenceObject { 194 | $ref: string; 195 | } 196 | 197 | export interface SchemaObject { 198 | nullable?: boolean; 199 | discriminator?: DiscriminatorObject; 200 | readOnly?: boolean; 201 | writeOnly?: boolean; 202 | xml?: XmlObject; 203 | externalDocs?: ExternalDocumentationObject; 204 | example?: any; 205 | examples?: any[]; 206 | deprecated?: boolean; 207 | type?: string; 208 | allOf?: (SchemaObject | ReferenceObject)[]; 209 | oneOf?: (SchemaObject | ReferenceObject)[]; 210 | anyOf?: (SchemaObject | ReferenceObject)[]; 211 | not?: SchemaObject | ReferenceObject; 212 | items?: SchemaObject | ReferenceObject; 213 | properties?: Record; 214 | additionalProperties?: SchemaObject | ReferenceObject | boolean; 215 | description?: string; 216 | format?: string; 217 | default?: any; 218 | title?: string; 219 | multipleOf?: number; 220 | maximum?: number; 221 | exclusiveMaximum?: boolean; 222 | minimum?: number; 223 | exclusiveMinimum?: boolean; 224 | maxLength?: number; 225 | minLength?: number; 226 | pattern?: string; 227 | maxItems?: number; 228 | minItems?: number; 229 | uniqueItems?: boolean; 230 | maxProperties?: number; 231 | minProperties?: number; 232 | required?: string[]; 233 | enum?: any[]; 234 | } 235 | 236 | export type SchemasObject = Record; 237 | 238 | export interface DiscriminatorObject { 239 | propertyName: string; 240 | mapping?: Record; 241 | } 242 | 243 | export interface XmlObject { 244 | name?: string; 245 | namespace?: string; 246 | prefix?: string; 247 | attribute?: boolean; 248 | wrapped?: boolean; 249 | } 250 | 251 | export type SecuritySchemeType = 'apiKey' | 'http' | 'oauth2' | 'openIdConnect'; 252 | 253 | export interface SecuritySchemeObject { 254 | type: SecuritySchemeType; 255 | description?: string; 256 | name?: string; 257 | in?: string; 258 | scheme?: string; 259 | bearerFormat?: string; 260 | flows?: OAuthFlowsObject; 261 | openIdConnectUrl?: string; 262 | } 263 | 264 | export interface OAuthFlowsObject { 265 | implicit?: OAuthFlowObject; 266 | password?: OAuthFlowObject; 267 | clientCredentials?: OAuthFlowObject; 268 | authorizationCode?: OAuthFlowObject; 269 | } 270 | 271 | export interface OAuthFlowObject { 272 | authorizationUrl?: string; 273 | tokenUrl?: string; 274 | refreshUrl?: string; 275 | scopes: ScopesObject; 276 | } 277 | 278 | export type ScopesObject = Record; 279 | export type SecurityRequirementObject = Record; 280 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Nest 2 | 3 | We would love for you to contribute to Nest and help make it even better than it is 4 | today! As a contributor, here are the guidelines we would like you to follow: 5 | 6 | - [Code of Conduct](#coc) 7 | - [Question or Problem?](#question) 8 | - [Issues and Bugs](#issue) 9 | - [Feature Requests](#feature) 10 | - [Submission Guidelines](#submit) 11 | - [Coding Rules](#rules) 12 | - [Commit Message Guidelines](#commit) 13 | 14 | 15 | 17 | 18 | ## Got a Question or Problem? 19 | 20 | **Do not open issues for general support questions as we want to keep GitHub issues for bug reports and feature requests.** You've got much better chances of getting your question answered on [Stack Overflow](https://stackoverflow.com/questions/tagged/nestjs) where the questions should be tagged with tag `nestjs`. 21 | 22 | Stack Overflow is a much better place to ask questions since: 23 | 24 | 25 | - questions and answers stay available for public viewing so your question / answer might help someone else 26 | - Stack Overflow's voting system assures that the best answers are prominently visible. 27 | 28 | To save your and our time, we will systematically close all issues that are requests for general support and redirect people to Stack Overflow. 29 | 30 | If you would like to chat about the question in real-time, you can reach out via [our gitter channel][gitter]. 31 | 32 | ## Found a Bug? 33 | If you find a bug in the source code, you can help us by 34 | [submitting an issue](#submit-issue) to our [GitHub Repository][github]. Even better, you can 35 | [submit a Pull Request](#submit-pr) with a fix. 36 | 37 | ## Missing a Feature? 38 | You can *request* a new feature by [submitting an issue](#submit-issue) to our GitHub 39 | Repository. If you would like to *implement* a new feature, please submit an issue with 40 | a proposal for your work first, to be sure that we can use it. 41 | Please consider what kind of change it is: 42 | 43 | * For a **Major Feature**, first open an issue and outline your proposal so that it can be 44 | discussed. This will also allow us to better coordinate our efforts, prevent duplication of work, 45 | and help you to craft the change so that it is successfully accepted into the project. For your issue name, please prefix your proposal with `[discussion]`, for example "[discussion]: your feature idea". 46 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 47 | 48 | ## Submission Guidelines 49 | 50 | ### Submitting an Issue 51 | 52 | Before you submit an issue, please search the issue tracker, maybe an issue for your problem already exists and the discussion might inform you of workarounds readily available. 53 | 54 | We want to fix all the issues as soon as possible, but before fixing a bug we need to reproduce and confirm it. In order to reproduce bugs we will systematically ask you to provide a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us wealth of important information without going back & forth to you with additional questions like: 55 | 56 | - version of NestJS used 57 | - 3rd-party libraries and their versions 58 | - and most importantly - a use-case that fails 59 | 60 | 64 | 65 | 66 | 67 | Unfortunately, we are not able to investigate / fix bugs without a minimal reproduction, so if we don't hear back from you we are going to close an issue that don't have enough info to be reproduced. 68 | 69 | You can file new issues by filling out our [new issue form](https://github.com/nestjs/nest/issues/new). 70 | 71 | 72 | ### Submitting a Pull Request (PR) 73 | Before you submit your Pull Request (PR) consider the following guidelines: 74 | 75 | 1. Search [GitHub](https://github.com/nestjs/nest/pulls) for an open or closed PR 76 | that relates to your submission. You don't want to duplicate effort. 77 | 79 | 1. Fork the nestjs/nest repo. 80 | 1. Make your changes in a new git branch: 81 | 82 | ```shell 83 | git checkout -b my-fix-branch master 84 | ``` 85 | 86 | 1. Create your patch, **including appropriate test cases**. 87 | 1. Follow our [Coding Rules](#rules). 88 | 1. Run the full Nest test suite, as described in the [developer documentation][dev-doc], 89 | and ensure that all tests pass. 90 | 1. Commit your changes using a descriptive commit message that follows our 91 | [commit message conventions](#commit). Adherence to these conventions 92 | is necessary because release notes are automatically generated from these messages. 93 | 94 | ```shell 95 | git commit -a 96 | ``` 97 | Note: the optional commit `-a` command line option will automatically "add" and "rm" edited files. 98 | 99 | 1. Push your branch to GitHub: 100 | 101 | ```shell 102 | git push origin my-fix-branch 103 | ``` 104 | 105 | 1. In GitHub, send a pull request to `nestjs:master`. 106 | * If we suggest changes then: 107 | * Make the required updates. 108 | * Re-run the Nest test suites to ensure tests are still passing. 109 | * Rebase your branch and force push to your GitHub repository (this will update your Pull Request): 110 | 111 | ```shell 112 | git rebase master -i 113 | git push -f 114 | ``` 115 | 116 | That's it! Thank you for your contribution! 117 | 118 | #### After your pull request is merged 119 | 120 | After your pull request is merged, you can safely delete your branch and pull the changes 121 | from the main (upstream) repository: 122 | 123 | * Delete the remote branch on GitHub either through the GitHub web UI or your local shell as follows: 124 | 125 | ```shell 126 | git push origin --delete my-fix-branch 127 | ``` 128 | 129 | * Check out the master branch: 130 | 131 | ```shell 132 | git checkout master -f 133 | ``` 134 | 135 | * Delete the local branch: 136 | 137 | ```shell 138 | git branch -D my-fix-branch 139 | ``` 140 | 141 | * Update your master with the latest upstream version: 142 | 143 | ```shell 144 | git pull --ff upstream master 145 | ``` 146 | 147 | ## Coding Rules 148 | To ensure consistency throughout the source code, keep these rules in mind as you are working: 149 | 150 | * All features or bug fixes **must be tested** by one or more specs (unit-tests). 151 | 154 | * We follow [Google's JavaScript Style Guide][js-style-guide], but wrap all code at 155 | **100 characters**. An automated formatter is available (`npm run format`). 156 | 157 | ## Commit Message Guidelines 158 | 159 | We have very precise rules over how our git commit messages can be formatted. This leads to **more 160 | readable messages** that are easy to follow when looking through the **project history**. But also, 161 | we use the git commit messages to **generate the Nest change log**. 162 | 163 | ### Commit Message Format 164 | Each commit message consists of a **header**, a **body** and a **footer**. The header has a special 165 | format that includes a **type**, a **scope** and a **subject**: 166 | 167 | ``` 168 | (): 169 | 170 | 171 | 172 |