typescript-rest-swagger
Version:
Generate Swagger files from a typescript-rest project
406 lines (349 loc) • 16.8 kB
text/typescript
import * as debug from 'debug';
import * as fs from 'fs';
import * as _ from 'lodash';
import * as mkdirp from 'mkdirp';
import * as pathUtil from 'path';
import * as YAML from 'yamljs';
import { Specification, SwaggerConfig } from '../config';
import {
ArrayType, EnumerateType, Metadata, Method, ObjectType, Parameter,
Property, ReferenceType, ResponseType, Type
} from '../metadata/metadataGenerator';
import { Swagger } from './swagger';
export class SpecGenerator {
private debugger = debug('typescript-rest-swagger:spec-generator');
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 => {
mkdirp(swaggerDir).then(() => {
this.debugger('Saving specs json file to folder: %j', swaggerDir);
fs.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);
fs.writeFile(`${swaggerDir}/swagger.yaml`, 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 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) { spec.host = this.config.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 => {
this.debugger('Generating definition for type: %s', typeName);
const referenceType = this.metadata.referenceTypes[typeName];
this.debugger('Metadata for referenced Type: %j', referenceType);
definitions[referenceType.typeName] = {
description: referenceType.description,
properties: this.buildProperties(referenceType.properties),
type: 'object'
};
const requiredFields = referenceType.properties.filter(p => p.required).map(p => p.name);
if (requiredFields && requiredFields.length) {
definitions[referenceType.typeName].required = requiredFields;
}
if (referenceType.additionalProperties) {
definitions[referenceType.typeName].additionalProperties = this.buildAdditionalProperties(referenceType.additionalProperties);
}
this.debugger('Generated Definition for type %s: %j', typeName, definitions[referenceType.typeName]);
});
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 = pathUtil.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 buildAdditionalProperties(properties: Array<Property>) {
const swaggerAdditionalProperties: { [ref: string]: string } = {};
properties.forEach(property => {
const swaggerType = this.getSwaggerType(property.type);
if (swaggerType.$ref) {
swaggerAdditionalProperties['$ref'] = swaggerType.$ref;
}
});
return swaggerAdditionalProperties;
}
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: Type) {
const swaggerType = this.getSwaggerTypeForPrimitiveType(type);
if (swaggerType) {
return swaggerType;
}
const arrayType = type as ArrayType;
if (arrayType.elementType) {
return this.getSwaggerTypeForArrayType(arrayType);
}
const enumType = type as EnumerateType;
if (enumType.enumMembers) {
return this.getSwaggerTypeForEnumType(enumType);
}
const refType = type as ReferenceType;
if (refType.properties && refType.description !== undefined) {
return this.getSwaggerTypeForReferenceType(type as ReferenceType);
}
const objectType = type as ObjectType;
return this.getSwaggerTypeForObjectType(objectType);
}
private getSwaggerTypeForPrimitiveType(type: Type) {
const typeMap: { [name: string]: Swagger.Schema } = {
binary: { type: 'string', format: 'binary' },
boolean: { type: 'boolean' },
buffer: { type: 'file' },
// buffer: { type: 'string', format: 'base64' },
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: { type: 'object' },
string: { type: 'string' },
void: { type: 'void' },
};
return typeMap[type.typeName];
}
private getSwaggerTypeForObjectType(objectType: ObjectType): Swagger.Schema {
return { type: 'object', properties: this.buildProperties(objectType.properties) };
}
private getSwaggerTypeForArrayType(arrayType: ArrayType): Swagger.Schema {
return { type: 'array', items: this.getSwaggerType(arrayType.elementType) };
}
private getSwaggerTypeForEnumType(enumType: EnumerateType): Swagger.Schema {
function getDerivedTypeFromValues(values: Array<any>): string {
return values.reduce((derivedType: string, item: any) => {
const currentType = typeof item;
derivedType = derivedType && derivedType !== currentType ? 'string' : currentType;
return derivedType;
}, null);
}
const enumValues = enumType.enumMembers.map(member => member as string) as [string];
return {
enum: enumType.enumMembers.map(member => member as string) as [string],
type: getDerivedTypeFromValues(enumValues),
};
}
private getSwaggerTypeForReferenceType(referenceType: ReferenceType): Swagger.Schema {
return { $ref: `#/definitions/${referenceType.typeName}` };
}
}