"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SpecGenerator3 = void 0;
const runtime_1 = require("@tsoa/runtime");
const merge_anything_1 = require("merge-anything");
const ts_deepmerge_1 = require("ts-deepmerge");
const isVoidType_1 = require("../utils/isVoidType");
const validatorUtils_1 = require("../utils/validatorUtils");
const pathUtils_1 = require("./../utils/pathUtils");
const swaggerUtils_1 = require("./../utils/swaggerUtils");
const specGenerator_1 = require("./specGenerator");
/**
 * TODO:
 * Handle formData parameters
 * Handle requestBodies of type other than json
 * Handle requestBodies as reusable objects
 * Handle headers, examples, responses, etc.
 * Cleaner interface between SpecGenerator2 and SpecGenerator3
 * Also accept OpenAPI 3.0.0 metadata, like components/securitySchemes instead of securityDefinitions
 */
class SpecGenerator3 extends specGenerator_1.SpecGenerator {
    constructor(metadata, config) {
        super(metadata, config);
        this.metadata = metadata;
        this.config = config;
    }
    GetSpec() {
        let spec = {
            openapi: '3.0.0',
            components: this.buildComponents(),
            info: this.buildInfo(),
            paths: this.buildPaths(),
            servers: this.buildServers(),
            tags: this.config.tags,
        };
        if (this.config.spec) {
            this.config.specMerging = this.config.specMerging || 'immediate';
            const mergeFuncs = {
                immediate: Object.assign,
                recursive: merge_anything_1.merge,
                deepmerge: (spec, merge) => (0, ts_deepmerge_1.merge)(spec, merge),
            };
            spec = mergeFuncs[this.config.specMerging](spec, this.config.spec);
        }
        return spec;
    }
    buildInfo() {
        const info = {
            title: this.config.name || '',
        };
        if (this.config.version) {
            info.version = this.config.version;
        }
        if (this.config.description) {
            info.description = this.config.description;
        }
        if (this.config.termsOfService) {
            info.termsOfService = this.config.termsOfService;
        }
        if (this.config.license) {
            info.license = { name: this.config.license };
        }
        if (this.config.contact) {
            info.contact = this.config.contact;
        }
        return info;
    }
    buildComponents() {
        const components = {
            examples: {},
            headers: {},
            parameters: {},
            requestBodies: {},
            responses: {},
            schemas: this.buildSchema(),
            securitySchemes: {},
        };
        if (this.config.securityDefinitions) {
            components.securitySchemes = this.translateSecurityDefinitions(this.config.securityDefinitions);
        }
        return components;
    }
    translateSecurityDefinitions(definitions) {
        const defs = {};
        Object.keys(definitions).forEach(key => {
            if (definitions[key].type === 'basic') {
                defs[key] = {
                    scheme: 'basic',
                    type: 'http',
                };
            }
            else if (definitions[key].type === 'oauth2') {
                /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
                const definition = definitions[key];
                const oauth = (defs[key] || {
                    type: 'oauth2',
                    description: definitions[key].description,
                    flows: (this.hasOAuthFlows(definition) && definition.flows) || {},
                });
                if (this.hasOAuthFlow(definition) && definition.flow === 'password') {
                    oauth.flows.password = { tokenUrl: definition.tokenUrl, scopes: definition.scopes || {} };
                }
                else if (this.hasOAuthFlow(definition) && definition.flow === 'accessCode') {
                    oauth.flows.authorizationCode = { tokenUrl: definition.tokenUrl, authorizationUrl: definition.authorizationUrl, scopes: definition.scopes || {} };
                }
                else if (this.hasOAuthFlow(definition) && definition.flow === 'application') {
                    oauth.flows.clientCredentials = { tokenUrl: definition.tokenUrl, scopes: definition.scopes || {} };
                }
                else if (this.hasOAuthFlow(definition) && definition.flow === 'implicit') {
                    oauth.flows.implicit = { authorizationUrl: definition.authorizationUrl, scopes: definition.scopes || {} };
                }
                defs[key] = oauth;
            }
            else {
                defs[key] = definitions[key];
            }
        });
        return defs;
    }
    hasOAuthFlow(definition) {
        return !!definition.flow;
    }
    hasOAuthFlows(definition) {
        return !!definition.flows;
    }
    buildServers() {
        const prefix = this.config.disableBasePathPrefixSlash ? undefined : '/';
        const basePath = (0, pathUtils_1.normalisePath)(this.config.basePath, prefix, undefined, false);
        const scheme = this.config.schemes ? this.config.schemes[0] : 'https';
        const hosts = this.config.servers ? this.config.servers : this.config.host ? [this.config.host] : undefined;
        const convertHost = (host) => ({ url: `${scheme}://${host}${basePath}` });
        return (hosts?.map(convertHost) || [{ url: basePath }]);
    }
    buildSchema() {
        const schema = {};
        Object.keys(this.metadata.referenceTypeMap).map(typeName => {
            const referenceType = this.metadata.referenceTypeMap[typeName];
            if (referenceType.dataType === 'refObject') {
                const required = referenceType.properties.filter(p => this.isRequiredWithoutDefault(p) && !this.hasUndefined(p)).map(p => p.name);
                schema[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) {
                    schema[referenceType.refName].additionalProperties = this.buildAdditionalProperties(referenceType.additionalProperties);
                }
                else {
                    // Since additionalProperties was not explicitly set in the TypeScript interface for this model
                    //      ...we need to make a decision
                    schema[referenceType.refName].additionalProperties = this.determineImplicitAdditionalPropertiesValue();
                }
                if (referenceType.example) {
                    schema[referenceType.refName].example = referenceType.example;
                }
            }
            else if (referenceType.dataType === 'refEnum') {
                const enumTypes = this.determineTypesUsedInEnum(referenceType.enums);
                if (enumTypes.size === 1) {
                    schema[referenceType.refName] = {
                        description: referenceType.description,
                        enum: referenceType.enums,
                        type: enumTypes.has('string') ? 'string' : 'number',
                    };
                    if (this.config.xEnumVarnames && referenceType.enumVarnames !== undefined && referenceType.enums.length === referenceType.enumVarnames.length) {
                        schema[referenceType.refName]['x-enum-varnames'] = referenceType.enumVarnames;
                    }
                }
                else {
                    schema[referenceType.refName] = {
                        description: referenceType.description,
                        anyOf: [
                            {
                                type: 'number',
                                enum: referenceType.enums.filter(e => typeof e === 'number'),
                            },
                            {
                                type: 'string',
                                enum: referenceType.enums.filter(e => typeof e === 'string'),
                            },
                        ],
                    };
                }
                if (referenceType.example) {
                    schema[referenceType.refName].example = referenceType.example;
                }
            }
            else if (referenceType.dataType === 'refAlias') {
                const swaggerType = this.getSwaggerType(referenceType.type);
                const format = referenceType.format;
                const validators = Object.keys(referenceType.validators)
                    .filter(validatorUtils_1.shouldIncludeValidatorInSchema)
                    .reduce((acc, key) => {
                    return {
                        ...acc,
                        [key]: referenceType.validators[key].value,
                    };
                }, {});
                schema[referenceType.refName] = {
                    ...swaggerType,
                    default: referenceType.default || swaggerType.default,
                    example: referenceType.example,
                    format: format || swaggerType.format,
                    description: referenceType.description,
                    ...validators,
                };
            }
            else {
                (0, runtime_1.assertNever)(referenceType);
            }
            if (referenceType.deprecated) {
                schema[referenceType.refName].deprecated = true;
            }
        });
        return schema;
    }
    buildPaths() {
        const paths = {};
        this.metadata.controllers.forEach(controller => {
            const normalisedControllerPath = (0, pathUtils_1.normalisePath)(controller.path, '/');
            // construct documentation using all methods except @Hidden
            controller.methods
                .filter(method => !method.isHidden)
                .forEach(method => {
                const normalisedMethodPath = (0, pathUtils_1.normalisePath)(method.path, '/');
                let path = (0, pathUtils_1.normalisePath)(`${normalisedControllerPath}${normalisedMethodPath}`, '/', '', false);
                path = (0, pathUtils_1.convertColonPathParams)(path);
                paths[path] = paths[path] || {};
                this.buildMethod(controller.name, method, paths[path], controller.produces);
            });
        });
        return paths;
    }
    buildMethod(controllerName, method, pathObject, defaultProduces) {
        const pathMethod = (pathObject[method.method] = this.buildOperation(controllerName, method, defaultProduces));
        pathMethod.description = method.description;
        pathMethod.summary = method.summary;
        pathMethod.tags = method.tags;
        // Use operationId tag otherwise fallback to generated. Warning: This doesn't check uniqueness.
        pathMethod.operationId = method.operationId || pathMethod.operationId;
        if (method.deprecated) {
            pathMethod.deprecated = method.deprecated;
        }
        if (method.security) {
            pathMethod.security = method.security;
        }
        const bodyParams = method.parameters.filter(p => p.in === 'body');
        const bodyPropParams = method.parameters.filter(p => p.in === 'body-prop');
        const formParams = method.parameters.filter(p => p.in === 'formData');
        const queriesParams = method.parameters.filter(p => p.in === 'queries');
        pathMethod.parameters = method.parameters
            .filter(p => {
            return ['body', 'formData', 'request', 'body-prop', 'res', 'queries', 'request-prop'].indexOf(p.in) === -1;
        })
            .map(p => this.buildParameter(p));
        if (queriesParams.length > 1) {
            throw new Error('Only one queries parameter allowed per controller method.');
        }
        if (queriesParams.length === 1) {
            pathMethod.parameters.push(...this.buildQueriesParameter(queriesParams[0]));
        }
        if (bodyParams.length > 1) {
            throw new Error('Only one body parameter allowed per controller method.');
        }
        if (bodyParams.length > 0 && formParams.length > 0) {
            throw new Error('Either body parameter or form parameters allowed per controller method - not both.');
        }
        if (bodyPropParams.length > 0) {
            if (!bodyParams.length) {
                bodyParams.push({
                    in: 'body',
                    name: 'body',
                    parameterName: 'body',
                    required: true,
                    type: {
                        dataType: 'nestedObjectLiteral',
                        properties: [],
                    },
                    validators: {},
                    deprecated: false,
                });
            }
            const type = bodyParams[0].type;
            bodyPropParams.forEach((bodyParam) => {
                type.properties.push(bodyParam);
            });
        }
        if (bodyParams.length > 0) {
            pathMethod.requestBody = this.buildRequestBody(controllerName, method, bodyParams[0]);
        }
        else if (formParams.length > 0) {
            pathMethod.requestBody = this.buildRequestBodyWithFormData(controllerName, method, formParams);
        }
        method.extensions.forEach(ext => (pathMethod[ext.key] = ext.value));
    }
    buildOperation(controllerName, method, defaultProduces) {
        const swaggerResponses = {};
        method.responses.forEach((res) => {
            swaggerResponses[res.name] = {
                description: res.description,
            };
            if (res.schema && !(0, isVoidType_1.isVoidType)(res.schema)) {
                swaggerResponses[res.name].content = {};
                const produces = res.produces || defaultProduces || [swaggerUtils_1.DEFAULT_RESPONSE_MEDIA_TYPE];
                for (const p of produces) {
                    const { content } = swaggerResponses[res.name];
                    swaggerResponses[res.name].content = {
                        ...content,
                        [p]: {
                            schema: this.getSwaggerType(res.schema, this.config.useTitleTagsForInlineObjects ? this.getOperationId(controllerName, method) + 'Response' : undefined),
                        },
                    };
                }
                if (res.examples) {
                    let exampleCounter = 1;
                    const examples = res.examples.reduce((acc, ex, currentIndex) => {
                        const exampleLabel = res.exampleLabels?.[currentIndex];
                        return { ...acc, [exampleLabel === undefined ? `Example ${exampleCounter++}` : exampleLabel]: { value: ex } };
                    }, {});
                    for (const p of produces) {
                        /* eslint-disable @typescript-eslint/dot-notation */
                        (swaggerResponses[res.name].content || {})[p]['examples'] = examples;
                    }
                }
            }
            if (res.headers) {
                const headers = {};
                if (res.headers.dataType === 'refObject') {
                    headers[res.headers.refName] = {
                        schema: this.getSwaggerTypeForReferenceType(res.headers),
                        description: res.headers.description,
                    };
                }
                else if (res.headers.dataType === 'nestedObjectLiteral') {
                    res.headers.properties.forEach((each) => {
                        headers[each.name] = {
                            schema: this.getSwaggerType(each.type),
                            description: each.description,
                            required: this.isRequiredWithoutDefault(each),
                        };
                    });
                }
                else {
                    (0, runtime_1.assertNever)(res.headers);
                }
                swaggerResponses[res.name].headers = headers;
            }
        });
        const operation = {
            operationId: this.getOperationId(controllerName, method),
            responses: swaggerResponses,
        };
        return operation;
    }
    buildRequestBodyWithFormData(controllerName, method, parameters) {
        const required = [];
        const properties = {};
        for (const parameter of parameters) {
            const mediaType = this.buildMediaType(controllerName, method, parameter);
            properties[parameter.name] = mediaType.schema;
            if (this.isRequiredWithoutDefault(parameter)) {
                required.push(parameter.name);
            }
            if (parameter.deprecated) {
                properties[parameter.name].deprecated = parameter.deprecated;
            }
        }
        const requestBody = {
            required: required.length > 0,
            content: {
                'multipart/form-data': {
                    schema: {
                        type: 'object',
                        properties,
                        // An empty list required: [] is not valid.
                        // If all properties are optional, do not specify the required keyword.
                        ...(required && required.length && { required }),
                    },
                },
            },
        };
        return requestBody;
    }
    buildRequestBody(controllerName, method, parameter) {
        const mediaType = this.buildMediaType(controllerName, method, parameter);
        const consumes = method.consumes || swaggerUtils_1.DEFAULT_REQUEST_MEDIA_TYPE;
        const requestBody = {
            description: parameter.description,
            required: this.isRequiredWithoutDefault(parameter),
            content: {
                [consumes]: mediaType,
            },
        };
        return requestBody;
    }
    buildMediaType(controllerName, method, parameter) {
        const validators = Object.keys(parameter.validators)
            .filter(validatorUtils_1.shouldIncludeValidatorInSchema)
            .reduce((acc, key) => {
            return {
                ...acc,
                [key]: parameter.validators[key].value,
            };
        }, {});
        const mediaType = {
            schema: {
                ...this.getSwaggerType(parameter.type, this.config.useTitleTagsForInlineObjects ? this.getOperationId(controllerName, method) + 'RequestBody' : undefined),
                ...validators,
                ...(parameter.description && { description: parameter.description }),
            },
        };
        const parameterExamples = parameter.example;
        const parameterExampleLabels = parameter.exampleLabels;
        if (parameterExamples === undefined) {
            mediaType.example = parameterExamples;
        }
        else if (parameterExamples.length === 1) {
            mediaType.example = parameterExamples[0];
        }
        else {
            let exampleCounter = 1;
            mediaType.examples = parameterExamples.reduce((acc, ex, currentIndex) => {
                const exampleLabel = parameterExampleLabels?.[currentIndex];
                return { ...acc, [exampleLabel === undefined ? `Example ${exampleCounter++}` : exampleLabel]: { value: ex } };
            }, {});
        }
        return mediaType;
    }
    buildQueriesParameter(source) {
        if (source.type.dataType === 'refObject' || source.type.dataType === 'nestedObjectLiteral') {
            const properties = source.type.properties;
            return properties.map(property => this.buildParameter(this.queriesPropertyToQueryParameter(property)));
        }
        throw new Error(`Queries '${source.name}' parameter must be an object.`);
    }
    buildParameter(source) {
        const parameter = {
            description: source.description,
            in: source.in,
            name: source.name,
            required: this.isRequiredWithoutDefault(source),
            schema: {
                default: source.default,
                format: undefined,
            },
        };
        if (source.deprecated) {
            parameter.deprecated = true;
        }
        const parameterType = this.getSwaggerType(source.type);
        if (parameterType.format) {
            parameter.schema.format = this.throwIfNotDataFormat(parameterType.format);
        }
        if (parameterType.$ref) {
            parameter.schema = parameterType;
            return Object.assign(parameter, this.buildExamples(source));
        }
        const validatorObjs = {};
        Object.keys(source.validators)
            .filter(validatorUtils_1.shouldIncludeValidatorInSchema)
            .forEach(key => {
            validatorObjs[key] = source.validators[key].value;
        });
        if (source.type.dataType === 'any') {
            parameter.schema.type = 'string';
        }
        else {
            if (parameterType.type) {
                parameter.schema.type = this.throwIfNotDataType(parameterType.type);
            }
            parameter.schema.items = parameterType.items;
            parameter.schema.enum = parameterType.enum;
        }
        parameter.schema = Object.assign({}, parameter.schema, validatorObjs);
        return Object.assign(parameter, this.buildExamples(source));
    }
    buildExamples(source) {
        const { example: parameterExamples, exampleLabels } = source;
        if (parameterExamples === undefined) {
            return { example: undefined };
        }
        if (parameterExamples.length === 1) {
            return { example: parameterExamples[0] };
        }
        let exampleCounter = 1;
        const examples = parameterExamples.reduce((acc, ex, idx) => {
            const label = exampleLabels?.[idx];
            const name = label ?? `Example ${exampleCounter++}`;
            acc[name] = { value: ex };
            return acc;
        }, {});
        return { examples };
    }
    buildProperties(source) {
        const properties = {};
        source.forEach(property => {
            let swaggerType = this.getSwaggerType(property.type);
            const format = property.format;
            swaggerType.description = property.description;
            swaggerType.example = property.example;
            swaggerType.format = format || swaggerType.format;
            if (!swaggerType.$ref) {
                swaggerType.default = property.default;
                Object.keys(property.validators)
                    .filter(validatorUtils_1.shouldIncludeValidatorInSchema)
                    .forEach(key => {
                    swaggerType = { ...swaggerType, [key]: property.validators[key].value };
                });
            }
            if (property.deprecated) {
                swaggerType.deprecated = true;
            }
            if (property.extensions) {
                property.extensions.forEach(property => {
                    swaggerType[property.key] = property.value;
                });
            }
            properties[property.name] = swaggerType;
        });
        return properties;
    }
    getSwaggerTypeForReferenceType(referenceType) {
        return { $ref: `#/components/schemas/${encodeURIComponent(referenceType.refName)}` };
    }
    getSwaggerTypeForPrimitiveType(dataType) {
        if (dataType === 'any') {
            // Setting additionalProperties causes issues with code generators for OpenAPI 3
            // Therefore, we avoid setting it explicitly (since it's the implicit default already)
            return {};
        }
        else if (dataType === 'file') {
            return { type: 'string', format: 'binary' };
        }
        return super.getSwaggerTypeForPrimitiveType(dataType);
    }
    isNull(type) {
        return type.dataType === 'enum' && type.enums.length === 1 && type.enums[0] === null;
    }
    // Join disparate enums with the same type into one.
    //
    // grouping enums is helpful because it makes the spec more readable and it
    // bypasses a failure in openapi-generator caused by using anyOf with
    // duplicate types.
    groupEnums(types) {
        const returnTypes = [];
        const enumValuesByType = {};
        for (const type of types) {
            if (type.enum && type.type) {
                for (const enumValue of type.enum) {
                    if (!enumValuesByType[type.type]) {
                        enumValuesByType[type.type] = {};
                    }
                    enumValuesByType[type.type][String(enumValue)] = enumValue;
                }
            }
            // preserve non-enum types
            else {
                returnTypes.push(type);
            }
        }
        Object.keys(enumValuesByType).forEach(dataType => returnTypes.push({
            type: dataType,
            enum: Object.values(enumValuesByType[dataType]),
        }));
        return returnTypes;
    }
    removeDuplicateSwaggerTypes(types) {
        if (types.length === 1) {
            return types;
        }
        else {
            const typesSet = new Set();
            for (const type of types) {
                typesSet.add(JSON.stringify(type));
            }
            return Array.from(typesSet).map(typeString => JSON.parse(typeString));
        }
    }
    getSwaggerTypeForUnionType(type, title) {
        // Filter out nulls and undefineds
        const actualSwaggerTypes = this.removeDuplicateSwaggerTypes(this.groupEnums(type.types
            .filter(x => !this.isNull(x))
            .filter(x => x.dataType !== 'undefined')
            .map(x => this.getSwaggerType(x))));
        const nullable = type.types.some(x => this.isNull(x));
        if (nullable) {
            if (actualSwaggerTypes.length === 1) {
                const [swaggerType] = actualSwaggerTypes;
                // for ref union with null, use an allOf with a single
                // element since you can't attach nullable directly to a ref.
                // https://swagger.io/docs/specification/using-ref/#syntax
                if (swaggerType.$ref) {
                    return { allOf: [swaggerType], nullable };
                }
                // Note that null must be explicitly included in the list of enum values. Using nullable: true alone is not enough here.
                // https://swagger.io/docs/specification/data-models/enums/
                if (swaggerType.enum) {
                    swaggerType.enum.push(null);
                }
                return { ...(title && { title }), ...swaggerType, nullable };
            }
            else {
                return { ...(title && { title }), anyOf: actualSwaggerTypes, nullable };
            }
        }
        else {
            if (actualSwaggerTypes.length === 1) {
                return { ...(title && { title }), ...actualSwaggerTypes[0] };
            }
            else {
                return { ...(title && { title }), anyOf: actualSwaggerTypes };
            }
        }
    }
    getSwaggerTypeForIntersectionType(type, title) {
        return { allOf: type.types.map(x => this.getSwaggerType(x)), ...(title && { title }) };
    }
    getSwaggerTypeForEnumType(enumType, title) {
        const types = this.determineTypesUsedInEnum(enumType.enums);
        if (types.size === 1) {
            const type = types.values().next().value;
            const nullable = enumType.enums.includes(null) ? true : false;
            return { ...(title && { title }), type, enum: enumType.enums.map(member => (0, swaggerUtils_1.getValue)(type, member)), nullable };
        }
        else {
            const valuesDelimited = Array.from(types).join(',');
            throw new Error(`Enums can only have string or number values, but enum had ${valuesDelimited}`);
        }
    }
}
exports.SpecGenerator3 = SpecGenerator3;
//# sourceMappingURL=specGenerator3.js.map