@tsed/schema
Version:
JsonSchema module for Ts.ED Framework
504 lines (503 loc) • 20.7 kB
JavaScript
"use strict";
import { cleanObject } from "@tsed/core";
const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
const SCHEMA_PROPERTIES = [
"format",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"minLength",
"maxLength",
"multipleOf",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
"additionalProperties",
"pattern",
"enum",
"default"
];
const ARRAY_PROPERTIES = ["type", "items"];
const APPLICATION_JSON_REGEX = /^(application\/json|[^;\/ \t]+\/[^;\/ \t]+[+]json)[ \t]*(;.*)?$/;
const SUPPORTED_MIME_TYPES = {
APPLICATION_X_WWW_URLENCODED: "application/x-www-form-urlencoded",
MULTIPART_FORM_DATA: "multipart/form-data"
};
function fixRef(ref) {
return ref.replace("#/components/schemas/", "#/definitions/").replace("#/components/", "#/x-components/");
}
function fixRefs(obj) {
if (Array.isArray(obj)) {
obj.forEach(fixRefs);
}
else if (typeof obj === "object") {
for (let key in obj) {
if (key === "$ref") {
obj.$ref = fixRef(obj.$ref);
}
else {
fixRefs(obj[key]);
}
}
}
}
function isJsonMimeType(type) {
return new RegExp(APPLICATION_JSON_REGEX, "i").test(type);
}
function getSupportedMimeTypes(content) {
const MIME_VALUES = Object.keys(SUPPORTED_MIME_TYPES).map((key) => {
return SUPPORTED_MIME_TYPES[key];
});
return Object.keys(content).filter((key) => {
return MIME_VALUES.indexOf(key) > -1 || isJsonMimeType(key);
});
}
export function transformSecurity(securitySchemes) {
function map(security) {
const { scheme, type, name, bearerFormat, flows, ...props } = security;
switch (type) {
case "http":
if (scheme === "basic") {
return {
...props,
type: scheme
};
}
if (scheme === "bearer") {
return {
...props,
type: "apiKey",
name: "Authorization",
in: "header"
};
}
break;
case "oauth2":
const flowName = Object.keys(flows)[0];
const flow = flows[flowName];
let flowType = flowName;
if (flowType === "clientCredentials") {
flowType = "application";
}
else if (flowType === "authorizationCode") {
flowType = "accessCode";
}
return {
type,
flow: flowType,
authorizationUrl: flow.authorizationUrl,
tokenUrl: flow.tokenUrl,
scopes: flow.scopes
};
}
}
return Object.entries(securitySchemes).reduce((securityDefinitions, [key, security]) => {
const securityDefinition = map(security);
if (securityDefinition) {
securityDefinitions[key] = securityDefinition;
}
return securityDefinitions;
}, {});
}
export function transformInformation(server) {
let serverUrl = server.url;
const variables = server["variables"] || {};
for (const variable in variables) {
const variableObject = variables[variable] || {};
if (variableObject["default"]) {
const re = RegExp(`{${variable}}`, "g");
serverUrl = serverUrl.replace(re, variableObject["default"]);
}
}
const url = new URL(serverUrl);
return {
host: url.host ? url.host : undefined,
basePath: url.pathname,
schemes: url.protocol !== null ? [url.protocol.substring(0, url.protocol.length - 1)] : undefined
};
}
export class Converter {
constructor(spec) {
this.spec = JSON.parse(JSON.stringify(spec));
}
convertInfos() {
const server = this.spec.servers && this.spec.servers[0];
if (server) {
return transformInformation(server);
}
return {};
}
resolveReference(base, obj, shouldClone) {
if (!obj || !obj.$ref) {
return obj;
}
const ref = obj.$ref;
if (ref.startsWith("#")) {
const keys = ref.split("/").map((k) => k.replace(/~1/g, "/").replace(/~0/g, "~"));
keys.shift();
let cur = base;
keys.forEach((k) => {
cur = cur[k];
});
return shouldClone ? JSON.parse(JSON.stringify(cur)) : cur;
}
}
convert() {
const spec = {
swagger: "2.0",
...this.convertInfos(),
paths: this.spec.paths ? this.convertOperations(this.spec.paths, this.spec) : undefined
};
if (this.spec.components) {
this.convertSchemas();
this.convertSecurityDefinitions();
this.spec["x-components"] = this.spec.components;
delete this.spec.components;
fixRefs(this.spec);
}
delete this.spec.servers;
delete this.spec.openapi;
delete this.spec["x-components"];
return cleanObject({
...spec,
...this.spec
});
}
convertOperations(paths, spec) {
return Object.entries(paths).reduce((paths, [path, operation]) => {
const pathObject = (paths[path] = this.resolveReference({ ...spec, paths }, operation, true));
this.convertParameters(pathObject); // converts common parameters
Object.keys(pathObject).forEach((method) => {
if (HTTP_METHODS.indexOf(method) >= 0) {
const operation = (pathObject[method] = this.resolveReference({ ...spec, paths }, pathObject[method], true));
this.convertOperationParameters(operation);
this.convertResponses(operation);
}
});
return paths;
}, paths);
}
convertOperationParameters(operation) {
let content, contentKey, mediaRanges, mediaTypes;
operation.parameters = operation.parameters || [];
if (operation.requestBody) {
let param = this.resolveReference(this.spec, operation.requestBody, true);
// fixing external $ref in body
if (operation.requestBody.content) {
const type = getSupportedMimeTypes(operation.requestBody.content)[0];
const structuredObj = { content: {} };
const data = operation.requestBody.content[type];
if (data && data.schema && data.schema.$ref && !data.schema.$ref.startsWith("#")) {
param = this.resolveReference(this.spec, data.schema, true);
structuredObj["content"][`${type}`] = { schema: param };
param = structuredObj;
}
}
param.name = "body";
content = param.content;
if (content && Object.keys(content).length) {
mediaRanges = Object.keys(content).filter((mediaRange) => mediaRange.indexOf("/") > 0);
mediaTypes = mediaRanges.filter((range) => range.indexOf("*") < 0);
contentKey = getSupportedMimeTypes(content)[0];
delete param.content;
if ([SUPPORTED_MIME_TYPES.APPLICATION_X_WWW_URLENCODED, SUPPORTED_MIME_TYPES.MULTIPART_FORM_DATA].includes(contentKey)) {
operation.consumes = mediaTypes;
param.in = "formData";
param.schema = content[contentKey].schema;
param.schema = this.resolveReference(this.spec, param.schema, true);
if (param.schema.type === "object" && param.schema.properties) {
const required = param.schema.required || [];
Object.keys(param.schema.properties).forEach((name) => {
const schema = param.schema.properties[name];
// readOnly properties should not be sent in requests
if (!schema.readOnly) {
const formDataParam = {
name,
in: "formData",
schema
};
if (required.indexOf(name) >= 0) {
formDataParam.required = true;
}
operation.parameters?.push(formDataParam);
}
});
}
else {
operation.parameters.push(param);
}
}
else if (contentKey) {
operation.consumes = mediaTypes;
param.in = "body";
param.schema = content[contentKey].schema;
operation.parameters.push(param);
}
else if (mediaRanges) {
operation.consumes = mediaTypes || ["application/octet-stream"];
param.in = "body";
param.name = param.name || "file";
delete param.type;
param.schema = content[mediaRanges[0]].schema || {
type: "string",
format: "binary"
};
operation.parameters.push(param);
}
if (param.schema) {
this.convertSchema(param.schema, "request");
}
}
delete operation.requestBody;
}
this.convertParameters(operation);
}
convertParameters(obj) {
if (obj.parameters === undefined) {
return;
}
obj.parameters = obj.parameters || [];
(obj.parameters || []).forEach((param, i) => {
param = obj.parameters[i] = this.resolveReference(this.spec, param, false);
if (param.in !== "body") {
this.copySchemaProperties(param, SCHEMA_PROPERTIES);
this.copySchemaProperties(param, ARRAY_PROPERTIES);
this.copySchemaXProperties(param);
if (!param.description) {
const schema = this.resolveReference(this.spec, param.schema, false);
if (!!schema && schema.description) {
param.description = schema.description;
}
}
if (param.example !== undefined) {
param["x-example"] = param.example;
}
delete param.schema;
delete param.allowReserved;
delete param.example;
}
if (param.type === "array") {
let style = param.style || (param.in === "query" || param.in === "cookie" ? "form" : "simple");
switch (style) {
case "matrix":
param.collectionFormat = param.explode ? undefined : "csv";
break;
case "label":
param.collectionFormat = undefined;
break;
case "simple":
param.collectionFormat = "csv";
break;
case "spaceDelimited":
param.collectionFormat = "ssv";
break;
case "pipeDelimited":
param.collectionFormat = "pipes";
break;
case "deepOpbject":
param.collectionFormat = "multi";
break;
case "form":
param.collectionFormat = param.explode === false ? "csv" : "multi";
break;
}
}
delete param.style;
delete param.explode;
});
}
copySchemaProperties(obj, props) {
let schema = this.resolveReference(this.spec, obj.schema, true);
if (!schema) {
return;
}
props.forEach((prop) => {
const value = schema[prop];
if (prop === "additionalProperties" && typeof value === "boolean") {
return;
}
if (value !== undefined) {
obj[prop] = value;
}
});
}
copySchemaXProperties(obj) {
let schema = this.resolveReference(this.spec, obj.schema, true);
if (!schema) {
return;
}
Object.keys(schema).forEach((propName) => {
if (Reflect.hasOwnProperty.call(schema, propName) && !Reflect.hasOwnProperty.call(obj, propName) && propName.startsWith("x-")) {
obj[propName] = schema[propName];
}
});
}
convertResponses(operation) {
// var anySchema, code, content, jsonSchema, mediaRange, mediaType, response, resolved, headers
Object.keys(operation.responses || {}).forEach((code) => {
const response = (operation.responses[code] = this.resolveReference(this.spec, operation.responses[code], true));
if (response.content) {
let anySchema = null;
let jsonSchema = null;
Object.keys(response.content).forEach((mediaRange) => {
// produces and examples only allow media types, not ranges
// use application/octet-stream as a catch-all type
const mediaType = mediaRange.indexOf("*") < 0 ? mediaRange : "application/octet-stream";
if (!operation.produces) {
operation.produces = [mediaType];
}
else if (operation.produces.indexOf(mediaType) < 0) {
operation.produces.push(mediaType);
}
const content = response.content[mediaRange];
anySchema = anySchema || content.schema;
if (!jsonSchema && isJsonMimeType(mediaType)) {
jsonSchema = content.schema;
}
if (content.example) {
response.examples = response.examples || {};
response.examples[mediaType] = content.example;
}
});
if (anySchema) {
response.schema = jsonSchema || anySchema;
const resolved = this.resolveReference(this.spec, response.schema, true);
if (resolved && response.schema.$ref && !response.schema.$ref.startsWith("#")) {
response.schema = resolved;
}
this.convertSchema(response.schema, "response");
}
}
Object.keys(response.headers || {}).forEach((header) => {
// Always resolve headers when converting to v2.
const resolved = this.resolveReference(this.spec, response.headers[header], true);
// Headers should be converted like parameters.
if (resolved.schema) {
resolved.type = resolved.schema.type;
resolved.format = resolved.schema.format;
delete resolved.schema;
}
response.headers[header] = resolved;
});
delete response.content;
});
}
convertSchema(def, operationDirection) {
if (def.oneOf) {
delete def.oneOf;
if (def.discriminator) {
delete def.discriminator;
}
}
if (def.anyOf) {
delete def.anyOf;
if (def.discriminator) {
delete def.discriminator;
}
}
if (def.allOf) {
for (const i in def.allOf) {
this.convertSchema(def.allOf[i], operationDirection);
}
}
if (def.discriminator) {
if (def.discriminator.mapping) {
this.convertDiscriminatorMapping(def.discriminator.mapping);
}
def.discriminator = def.discriminator.propertyName;
}
switch (def.type) {
case "object":
if (def.properties) {
Object.keys(def.properties).forEach((propName) => {
if (def.properties[propName].writeOnly === true && operationDirection === "response") {
delete def.properties[propName];
}
else {
this.convertSchema(def.properties[propName], operationDirection);
delete def.properties[propName].writeOnly;
}
});
}
break;
case "array":
if (def.items) {
this.convertSchema(def.items, operationDirection);
}
}
if (def.nullable) {
def["x-nullable"] = true;
delete def.nullable;
}
// OpenAPI 3 has boolean "deprecated" on Schema, OpenAPI 2 does not
// Convert to x-deprecated for Autorest (and perhaps others)
if (def["deprecated"] !== undefined) {
// Move to x-deprecated, unless it is already defined
if (def["x-deprecated"] === undefined) {
def["x-deprecated"] = def.deprecated;
}
delete def.deprecated;
}
}
convertSchemas() {
this.spec.definitions = this.spec.components.schemas;
Object.keys(this.spec.definitions || {}).forEach((defName) => {
this.convertSchema(this.spec.definitions[defName]);
});
delete this.spec.components.schemas;
}
convertDiscriminatorMapping(mapping) {
Object.keys(mapping).forEach((payload) => {
const schemaNameOrRef = mapping[payload];
if (typeof schemaNameOrRef !== "string") {
console.warn(`Ignoring ${schemaNameOrRef} for ${payload} in discriminator.mapping.`);
return;
}
// payload may be a schema name or JSON Reference string.
// OAS3 spec limits schema names to ^[a-zA-Z0-9._-]+$
// Note: Valid schema name could be JSON file name without extension.
// Prefer schema name, with file name as fallback.
let schema;
if (/^[a-zA-Z0-9._-]+$/.test(schemaNameOrRef)) {
try {
schema = this.resolveReference(this.spec, { $ref: `#/components/schemas/${schemaNameOrRef}` }, false);
}
catch (err) {
console.debug(`Error resolving ${schemaNameOrRef} for ${payload} as schema name in discriminator.mapping: ${err}`);
}
}
// schemaNameRef is not a schema name. Try to resolve as JSON Ref.
if (!schema) {
try {
schema = this.resolveReference(this.spec, { $ref: schemaNameOrRef }, false);
}
catch (err) {
console.debug(`Error resolving ${schemaNameOrRef} for ${payload} in discriminator.mapping: ${err}`);
}
}
if (schema) {
// Swagger Codegen + OpenAPI Generator extension
// https://github.com/swagger-api/swagger-codegen/pull/4252
schema["x-discriminator-value"] = payload;
// AutoRest extension
// https://github.com/Azure/autorest/pull/474
schema["x-ms-discriminator-value"] = payload;
}
else {
console.warn(`Unable to resolve ${schemaNameOrRef} for ${payload} in discriminator.mapping.`);
}
});
}
convertSecurityDefinitions() {
if (this.spec.components.securitySchemes) {
this.spec.securityDefinitions = transformSecurity(this.spec.components.securitySchemes);
delete this.spec.components.securitySchemes;
}
}
}
export function transformToOS2(spec) {
return new Converter(spec).convert();
}