typescript-swagger
Version: 
Generate Swagger files from a decorator library like typescript-rest or a @decorators/express.
590 lines (503 loc) • 25.1 kB
text/typescript
import {Debugger} from "debug";
import {promises, writeFile} from 'fs';
import {castArray, union} from 'lodash';
import {posix} from 'path';
import {CompilerOptions} from "typescript";
import {stringify} from 'yamljs';
import {Specification, SwaggerConfig} from '../config';
import {useDebugger} from "../debug";
import {Metadata, MetadataGenerator, Method, Parameter, Property, ResponseType} from '../metadata/metadataGenerator';
import {Resolver} from "../metadata/resolver/type";
import {Swagger} from './index';
export async function generateDocumentation(swaggerConfig: SwaggerConfig, tsConfig: CompilerOptions) : Promise<string> {
    const metadata = new MetadataGenerator(swaggerConfig.entryFile, tsConfig, swaggerConfig.ignore, swaggerConfig.decoratorConfig).generate();
    await new SpecGenerator(metadata, swaggerConfig).generate();
    return Array.isArray(swaggerConfig.outputDirectory) ? swaggerConfig.outputDirectory.join('/') : swaggerConfig.outputDirectory;
}
export class SpecGenerator {
    private debugger : Debugger = useDebugger();
    constructor(private readonly metadata: Metadata, private readonly config: SwaggerConfig) { }
    public async generate(): Promise<void> {
        this.debugger('Generating swagger files.');
        this.debugger('Swagger Config: %j', this.config);
        this.debugger('Services Metadata: %j', this.metadata);
        let spec: any = this.getSwaggerSpec();
        if (this.config.outputFormat === Specification.OpenApi_3) {
            spec = await this.convertToOpenApiSpec(spec);
        }
        return new Promise<void>((resolve, reject) => {
            const swaggerDirs = castArray(this.config.outputDirectory);
            this.debugger('Saving specs to folders: %j', swaggerDirs);
            swaggerDirs.forEach((swaggerDir: string) => {
                promises.mkdir(swaggerDir, {recursive: true}).then(() => {
                    this.debugger('Saving specs json file to folder: %j', swaggerDir);
                    writeFile(`${swaggerDir}/swagger.json`, JSON.stringify(spec, null, '\t'), (err: any) => {
                        if (err) {
                            return reject(err);
                        }
                        if (this.config.yaml) {
                            this.debugger('Saving specs yaml file to folder: %j', swaggerDir);
                            writeFile(`${swaggerDir}/swagger.yaml`, stringify(spec, 1000), (errYaml: any) => {
                                if (errYaml) {
                                    return reject(errYaml);
                                }
                                this.debugger('Generated files saved to folder: %j', swaggerDir);
                                resolve();
                            });
                        } else {
                            this.debugger('Generated files saved to folder: %j', swaggerDir);
                            resolve();
                        }
                    });
                }).catch(reject);
            });
        });
    }
    public getMetaData() {
        return this.metadata;
    }
    public getSwaggerSpec() {
        let spec: Swagger.Spec = {
            basePath: this.config.basePath,
            definitions: this.buildDefinitions(),
            info: {},
            paths: this.buildPaths(),
            swagger: '2.0'
        };
        spec.securityDefinitions = this.config.securityDefinitions
            ? this.config.securityDefinitions
            : {};
        if (this.config.consumes) {
            spec.consumes = this.config.consumes;
        }
        if (this.config.produces) {
            spec.produces = this.config.produces;
        }
        if (this.config.description) {
            spec.info.description = this.config.description;
        }
        if (this.config.license) {
            spec.info.license = {
                name: this.config.license
            };
        }
        if (this.config.name) {
            spec.info.title = this.config.name;
        }
        if (this.config.version) {
            spec.info.version = this.config.version;
        }
        if (this.config.host) {
            const url = new URL(this.config.host);
            let host : string = (url.host + url.pathname).replace(/([^:]\/)\/+/g, "$1");
            host = host.substr(-1, 1) === '/' ? host.substr(0, host.length -1) : host;
            spec.host = host;
        }
        if (this.config.spec) {
            spec = require('merge').recursive(spec, this.config.spec);
        }
        this.debugger('Generated specs: %j', spec);
        return spec;
    }
    public async getOpenApiSpec() {
        return await this.convertToOpenApiSpec(this.getSwaggerSpec());
    }
    private async convertToOpenApiSpec(spec: Swagger.Spec) {
        this.debugger('Converting specs to openapi 3.0');
        const converter = require('swagger2openapi');
        const options = {
            patch: true,
            warnOnly: true
        };
        const openapi = await converter.convertObj(spec, options);
        this.debugger('Converted to openapi 3.0: %j', openapi);
        return openapi.openapi;
    }
    private buildDefinitions() {
        const definitions: { [definitionsName: string]: Swagger.Schema } = {};
        Object.keys(this.metadata.referenceTypes).map(typeName => {
            const referenceType : Resolver.ReferenceType = this.metadata.referenceTypes[typeName];
            // const key : string = referenceType.typeName.replace('_', '');
            if (Resolver.isRefObjectType(referenceType)) {
                const required = referenceType.properties.filter((p: Property) => p.required).map((p: Property) => p.name);
                definitions[referenceType.refName] = {
                    description: referenceType.description,
                    properties: this.buildProperties(referenceType.properties),
                    required: required && required.length > 0 ? Array.from(new Set(required)) : undefined,
                    type: 'object',
                };
                if (referenceType.additionalProperties) {
                    definitions[referenceType.refName].additionalProperties = true;
                } else {
                    // Since additionalProperties was not explicitly set in the TypeScript interface for this model
                    //      ...we need to make a decision
                    definitions[referenceType.refName].additionalProperties = true;
                }
                if (referenceType.example) {
                    // @ts-ignore
                    definitions[referenceType.refName].example = referenceType.example;
                }
            } else if (Resolver.isRefEnumType(referenceType)) {
                definitions[referenceType.refName] = {
                    description: referenceType.description,
                    enum: referenceType.members,
                    type: this.decideEnumType(referenceType.members, referenceType.refName),
                };
                if (referenceType.memberNames !== undefined && referenceType.members.length === referenceType.memberNames.length) {
                    // @ts-ignore
                    definitions[referenceType.refName]['x-enum-varnames'] = referenceType.memberNames;
                }
            } else if (Resolver.isRefAliasType(referenceType)) {
                const swaggerType = this.getSwaggerType(referenceType.type);
                const format = referenceType.format;
                const validators = Object.keys(referenceType.validators)
                    .filter(key => {
                        return !key.startsWith('is') && key !== 'minDate' && key !== 'maxDate';
                    })
                    .reduce((acc, key) => {
                        return {
                            ...acc,
                            [key]: referenceType.validators[key].value,
                        };
                    }, {});
                definitions[referenceType.refName] = {
                    ...(swaggerType as Swagger.Schema),
                    default: referenceType.default || swaggerType.default,
                    example: referenceType.example as {[p: string]: Swagger.Example},
                    format: format || swaggerType.format,
                    description: referenceType.description,
                    ...validators,
                };
            } else {
                console.log(referenceType);
            }
        });
        return definitions;
    }
    private buildPaths() {
        const paths: { [pathName: string]: Swagger.Path } = {};
        this.debugger('Generating paths declarations');
        this.metadata.controllers.forEach(controller => {
            this.debugger('Generating paths for controller: %s', controller.name);
            controller.methods.forEach(method => {
                this.debugger('Generating paths for method: %s', method.name);
                const path = posix.join('/', (controller.path ? controller.path : ''), method.path);
                paths[path] = paths[path] || {};
                method.consumes = union(controller.consumes, method.consumes);
                method.produces = union(controller.produces, method.produces);
                method.tags = union(controller.tags, method.tags);
                method.security = method.security || controller.security;
                method.responses = union(controller.responses, method.responses);
                const pathObject: any = paths[path];
                pathObject[method.method] = this.buildPathMethod(controller.name, method);
                this.debugger('Generated path for method %s: %j', method.name, pathObject[method.method]);
            });
        });
        return paths;
    }
    private buildPathMethod(controllerName: string, method: Method) {
        const pathMethod: any = this.buildOperation(controllerName, method);
        pathMethod.description = method.description;
        if (method.summary) {
            pathMethod.summary = method.summary;
        }
        if (method.deprecated) { pathMethod.deprecated = method.deprecated; }
        if (method.tags.length) { pathMethod.tags = method.tags; }
        if (method.security) {
            pathMethod.security = method.security.map(s => ({
                [s.name]: s.scopes || []
            }));
        }
        this.handleMethodConsumes(method, pathMethod);
        pathMethod.parameters = method.parameters
            .filter(p => (p.in !== 'param'))
            .map(p => this.buildParameter(p));
        method.parameters
            .filter(p => (p.in === 'param'))
            .forEach(p => {
                pathMethod.parameters.push(this.buildParameter({
                    description: p.description,
                    in: 'query',
                    name: p.name,
                    parameterName: p.parameterName,
                    required: false,
                    type: p.type
                }));
                pathMethod.parameters.push(this.buildParameter({
                    description: p.description,
                    in: 'formData',
                    name: p.name,
                    parameterName: p.parameterName,
                    required: false,
                    type: p.type
                }));
            });
        if (pathMethod.parameters.filter((p: Swagger.BaseParameter) => p.in === 'body').length > 1) {
            throw new Error('Only one body parameter allowed per controller method.');
        }
        return pathMethod;
    }
    private handleMethodConsumes(method: Method, pathMethod: any) {
        if (method.consumes.length) { pathMethod.consumes = method.consumes; }
        if ((!pathMethod.consumes || !pathMethod.consumes.length)) {
            if (method.parameters.some(p => (p.in === 'formData' && p.type.typeName === 'file'))) {
                pathMethod.consumes = pathMethod.consumes || [];
                pathMethod.consumes.push('multipart/form-data');
            } else if (this.hasFormParams(method)) {
                pathMethod.consumes = pathMethod.consumes || [];
                pathMethod.consumes.push('application/x-www-form-urlencoded');
            } else if (this.supportsBodyParameters(method.method)) {
                pathMethod.consumes = pathMethod.consumes || [];
                pathMethod.consumes.push('application/json');
            }
        }
    }
    private hasFormParams(method: Method) {
        return method.parameters.find(p => (p.in === 'formData'));
    }
    private supportsBodyParameters(method: string) {
        return ['post', 'put', 'patch'].some(m => m === method);
    }
    private buildParameter(parameter: Parameter): Swagger.Parameter {
        const swaggerParameter: any = {
            description: parameter.description,
            in: parameter.in,
            name: parameter.name,
            required: parameter.required
        };
        const parameterType = this.getSwaggerType(parameter.type);
        if (parameterType.$ref || parameter.in === 'body') {
            swaggerParameter.schema = parameterType;
        } else {
            swaggerParameter.type = parameterType.type;
            if (parameterType.items) {
                swaggerParameter.items = parameterType.items;
                if (parameter.collectionFormat || this.config.collectionFormat) {
                    swaggerParameter.collectionFormat = parameter.collectionFormat || this.config.collectionFormat;
                }
            }
        }
        if (parameterType.format) { swaggerParameter.format = parameterType.format; }
        if (parameter.default !== undefined) { swaggerParameter.default = parameter.default; }
        if (parameterType.enum) { swaggerParameter.enum = parameterType.enum; }
        return swaggerParameter;
    }
    private buildProperties(properties: Array<Property>) {
        const swaggerProperties: { [propertyName: string]: Swagger.Schema } = {};
        properties.forEach(property => {
            const swaggerType = this.getSwaggerType(property.type);
            if (!swaggerType.$ref) {
                swaggerType.description = property.description;
            }
            swaggerProperties[property.name] = swaggerType;
        });
        return swaggerProperties;
    }
    private decideEnumType(anEnum: Array<string | number>, nameOfEnum: string): 'string' | 'number' {
        const typesUsedInEnum = this.determineTypesUsedInEnum(anEnum);
        const badEnumErrorMessage = () => {
            const valuesDelimited = Array.from(typesUsedInEnum).join(',');
            return `Enums can only have string or number values, but enum ${nameOfEnum} had ${valuesDelimited}`;
        };
        let enumTypeForSwagger: 'string' | 'number' = 'string';
        if (typesUsedInEnum.has('string') && typesUsedInEnum.size === 1) {
            enumTypeForSwagger = 'string';
        } else if (typesUsedInEnum.has('number') && typesUsedInEnum.size === 1) {
            enumTypeForSwagger = 'number';
        } else if(typesUsedInEnum.size === 2 && typesUsedInEnum.has('number') && typesUsedInEnum.has('string')) {
            enumTypeForSwagger = 'string';
        } else {
            throw new Error(badEnumErrorMessage());
        }
        return enumTypeForSwagger;
    }
    private buildOperation(controllerName: string, method: Method) {
        const operation: any = {
            operationId: this.getOperationId(controllerName, method.name),
            produces: [],
            responses: {}
        };
        const methodReturnTypes = new Set<string>();
        method.responses.forEach((res: ResponseType) => {
            operation.responses[res.status] = {
                description: res.description
            };
            if (res.schema) {
                const swaggerType = this.getSwaggerType(res.schema);
                if (swaggerType.type !== 'void') {
                    operation.responses[res.status]['schema'] = swaggerType;
                }
                methodReturnTypes.add(this.getMimeType(swaggerType));
            }
            if (res.examples) {
                operation.responses[res.status]['examples'] = { 'application/json': res.examples };
            }
        });
        this.handleMethodProduces(method, operation, methodReturnTypes);
        return operation;
    }
    private getMimeType(swaggerType: Swagger.Schema) {
        if (swaggerType.$ref || swaggerType.type === 'array' || swaggerType.type === 'object') {
            return 'application/json';
        } else if (swaggerType.type === 'string' && swaggerType.format === 'binary') {
            return 'application/octet-stream';
        } else {
            return 'text/html';
        }
    }
    private handleMethodProduces(method: Method, operation: any, methodReturnTypes: Set<string>) {
        if (method.produces.length) {
            operation.produces = method.produces;
        } else if (methodReturnTypes && methodReturnTypes.size > 0) {
            operation.produces = Array.from(methodReturnTypes);
        }
    }
    private getOperationId(controllerName: string, methodName: string) {
        const controllerNameWithoutSuffix = controllerName.replace(new RegExp('Controller$'), '');
        return `${controllerNameWithoutSuffix}${methodName.charAt(0).toUpperCase() + methodName.substr(1)}`;
    }
    private getSwaggerType(type: Resolver.BaseType) : Swagger.BaseSchema | Swagger.Schema {
        if (Resolver.isVoidType(type)) {
            return {} as Swagger.BaseSchema;
        } else if (Resolver.isReferenceType(type)) {
            return this.getSwaggerTypeForReferenceType(type);
        } else if (
            type.typeName === 'any' ||
            type.typeName === 'binary' ||
            type.typeName === 'boolean' ||
            type.typeName === 'buffer' ||
            type.typeName === 'byte' ||
            type.typeName === 'date' ||
            type.typeName === 'datetime' ||
            type.typeName === 'double' ||
            type.typeName === 'float' ||
            type.typeName === 'file' ||
            type.typeName === 'integer' ||
            type.typeName === 'long' ||
            type.typeName === 'object' ||
            type.typeName === 'string'
        ) {
            return this.getSwaggerTypeForPrimitiveType(type.typeName);
        } else if (Resolver.isArrayType(type)) {
            return this.getSwaggerTypeForArrayType(type);
        } else if (Resolver.isEnumType(type)) {
            return this.getSwaggerTypeForEnumType(type);
        } else if (Resolver.isUnionType(type)) {
            return this.getSwaggerTypeForUnionType(type);
        } else if (Resolver.isIntersectionType(type)) {
            return this.getSwaggerTypeForIntersectionType(type);
        } else if (Resolver.isNestedObjectLiteralType(type)) {
            return this.getSwaggerTypeForObjectLiteral(type);
        } else {
            console.log(type);
        }
        return {} as Swagger.BaseSchema;
    }
    protected isNull(type: Resolver.Type) {
        return Resolver.isEnumType(type) && type.members.length === 1 && type.members[0] === null;
    }
    protected getSwaggerTypeForUnionType(type: Resolver.UnionType) {
        if (type.members.every((subType: Resolver.Type) => subType.typeName === 'enum')) {
            const mergedEnum: Resolver.EnumType = { typeName: 'enum', members: [] };
            type.members.forEach((t: Resolver.Type) => {
                mergedEnum.members = [...mergedEnum.members, ...(t as Resolver.EnumType).members];
            });
            return this.getSwaggerTypeForEnumType(mergedEnum);
        } else if (type.members.length === 2 && type.members.find((typeInUnion: Resolver.Type) => typeInUnion.typeName === 'enum' && typeInUnion.members.includes(null))) {
            // Backwards compatible representation of dataType or null, $ref does not allow any sibling attributes, so we have to bail out
            const nullEnumIndex = type.members.findIndex((a: Resolver.Type) => Resolver.isEnumType(a) && a.members.includes(null));
            const typeIndex = nullEnumIndex === 1 ? 0 : 1;
            const swaggerType = this.getSwaggerType(type.members[typeIndex]);
            const isRef = !!swaggerType.$ref;
            if (isRef) {
                return { type: 'object' };
            } else {
                // @ts-ignore
                swaggerType['x-nullable'] = true;
                return swaggerType;
            }
        }
        if(type.members.length === 2) {
            let index = type.members.findIndex((member: Resolver.Type) => Resolver.isArrayType(member));
            if(index !== -1) {
                const otherIndex = index === 0 ? 1 : 0;
                if((type.members[index] as Resolver.ArrayType).elementType.typeName === type.members[otherIndex].typeName) {
                    return this.getSwaggerType(type.members[otherIndex]);
                }
            }
            index = type.members.findIndex((member: Resolver.Type) => Resolver.isAnyType(member));
            if(index !== -1) {
                const otherIndex = index === 0 ? 1 : 0;
                if(Resolver.isAnyType(type.members[index])) {
                    return this.getSwaggerType(type.members[otherIndex]);
                }
            }
        }
        return { type: 'object' };
    }
    private getSwaggerTypeForPrimitiveType(type: Resolver.PrimitiveTypeLiteral) {
        const map: Record<Resolver.PrimitiveTypeLiteral, Swagger.Schema> = {
            any: {
                // While the any type is discouraged, it does explicitly allows anything, so it should always allow additionalProperties
                additionalProperties: true,
            },
            binary: { type: 'string', format: 'binary' },
            boolean: { type: 'boolean' },
            buffer: { type: 'string', format: 'byte' },
            byte: { type: 'string', format: 'byte' },
            date: { type: 'string', format: 'date' },
            datetime: { type: 'string', format: 'date-time' },
            double: { type: 'number', format: 'double' },
            file: { type: 'file' },
            float: { type: 'number', format: 'float' },
            integer: { type: 'integer', format: 'int32' },
            long: { type: 'integer', format: 'int64' },
            object: {
                additionalProperties: true,
                type: 'object',
            },
            string: { type: 'string' },
        };
        return map[type];
    }
    private getSwaggerTypeForArrayType(arrayType: Resolver.ArrayType): Swagger.Schema {
        return { type: 'array', items: this.getSwaggerType(arrayType.elementType) };
    }
    protected getSwaggerTypeForIntersectionType(type: Resolver.IntersectionType) : Swagger.Schema {
        return { allOf: type.members.map((x: Resolver.Type) => this.getSwaggerType(x)) };
    }
    protected getSwaggerTypeForEnumType(enumType: Resolver.EnumType) : Swagger.Schema2 | Swagger.Schema3 {
        const types = this.determineTypesUsedInEnum(enumType.members);
        if (types.size === 1) {
            const type = types.values().next().value;
            const nullable = !!enumType.members.includes(null);
            return { type: type, enum: enumType.members.map((member: string | number | boolean | null) => (member === null ? null : String(member))), nullable: nullable };
        } else {
            const valuesDelimited = Array.from(types).join(',');
            throw new Error(`Enums can only have string or number values, but enum had ${valuesDelimited}`);
        }
    }
    public getSwaggerTypeForObjectLiteral(objectLiteral: Resolver.NestedObjectLiteralType) : Swagger.Schema {
        const properties = this.buildProperties(objectLiteral.properties);
        const additionalProperties = objectLiteral.additionalProperties && this.getSwaggerType(objectLiteral.additionalProperties);
        const required = objectLiteral.properties.filter((prop: Property) => prop.required).map((prop: Property) => prop.name);
        // An empty list required: [] is not valid.
        // If all properties are optional, do not specify the required keyword.
        return {
            properties: properties,
            ...(additionalProperties && { additionalProperties: additionalProperties }),
            ...(required && required.length && { required: required }),
            type: 'object',
        };
    }
    private getSwaggerTypeForReferenceType(referenceType: Resolver.ReferenceType): Swagger.Schema {
        return { $ref: `#/definitions/${referenceType.refName}` };
    }
    protected determineTypesUsedInEnum(anEnum: Array<string | number | boolean | null>) {
        return anEnum.reduce((theSet, curr) => {
            const typeUsed = curr === null ? 'number' : typeof curr;
            theSet.add(typeUsed);
            return theSet;
        }, new Set<'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function'>());
    }
}