import { dirname } from 'path'; import * as URLtemplate from 'url-template'; import { ExtendedOpenAPIOperation } from '../services'; import { FieldModel } from '../services/models'; import { OpenAPIParser } from '../services/OpenAPIParser'; import { OpenAPIEncoding, OpenAPIMediaType, OpenAPIParameter, OpenAPIParameterStyle, OpenAPIRequestBody, OpenAPIResponse, OpenAPISchema, OpenAPIServer, Referenced, } from '../types'; import { IS_BROWSER } from './dom'; import { isNumeric, removeQueryString, resolveUrl, isArray } from './helpers'; function isWildcardStatusCode(statusCode: string | number): statusCode is string { return typeof statusCode === 'string' && /\dxx/i.test(statusCode); } export function isStatusCode(statusCode: string) { return statusCode === 'default' || isNumeric(statusCode) || isWildcardStatusCode(statusCode); } export function getStatusCodeType(statusCode: string | number, defaultAsError = false): string { if (statusCode === 'default') { return defaultAsError ? 'error' : 'success'; } let code = typeof statusCode === 'string' ? parseInt(statusCode, 10) : statusCode; if (isWildcardStatusCode(statusCode)) { code *= 100; // parseInt('2xx') parses to 2 } if (code < 100 || code > 599) { throw new Error('invalid HTTP code'); } let res = 'success'; if (code >= 300 && code < 400) { res = 'redirect'; } else if (code >= 400) { res = 'error'; } else if (code < 200) { res = 'info'; } return res; } const operationNames = { get: true, post: true, put: true, head: true, patch: true, delete: true, options: true, $ref: true, }; export function isOperationName(key: string): boolean { return key in operationNames; } export function getOperationSummary(operation: ExtendedOpenAPIOperation): string { return ( operation.summary || operation.operationId || (operation.description && operation.description.substring(0, 50)) || operation.pathName || '' ); } const schemaKeywordTypes = { multipleOf: 'number', maximum: 'number', exclusiveMaximum: 'number', minimum: 'number', exclusiveMinimum: 'number', maxLength: 'string', minLength: 'string', pattern: 'string', contentEncoding: 'string', contentMediaType: 'string', items: 'array', maxItems: 'array', minItems: 'array', uniqueItems: 'array', maxProperties: 'object', minProperties: 'object', required: 'object', additionalProperties: 'object', unevaluatedProperties: 'object', properties: 'object', patternProperties: 'object', }; export function detectType(schema: OpenAPISchema): string { if (schema.type !== undefined && !isArray(schema.type)) { return schema.type; } const keywords = Object.keys(schemaKeywordTypes); for (const keyword of keywords) { const type = schemaKeywordTypes[keyword]; if (schema[keyword] !== undefined) { return type; } } return 'any'; } export function isPrimitiveType( schema: OpenAPISchema, type: string | string[] | undefined = schema.type, ) { if (schema.oneOf !== undefined || schema.anyOf !== undefined) { return false; } let isPrimitive = true; const isArrayType = isArray(type); if (type === 'object' || (isArrayType && type?.includes('object'))) { isPrimitive = schema.properties !== undefined ? Object.keys(schema.properties).length === 0 : schema.additionalProperties === undefined && schema.unevaluatedProperties === undefined; } if ( schema.items !== undefined && (type === 'array' || (isArrayType && type?.includes('array'))) ) { isPrimitive = isPrimitiveType(schema.items, schema.items.type); } return isPrimitive; } export function isJsonLike(contentType: string): boolean { return contentType.search(/json/i) !== -1; } export function isFormUrlEncoded(contentType: string): boolean { return contentType === 'application/x-www-form-urlencoded'; } function delimitedEncodeField(fieldVal: any, fieldName: string, delimiter: string): string { if (isArray(fieldVal)) { return fieldVal.map(v => v.toString()).join(delimiter); } else if (typeof fieldVal === 'object') { return Object.keys(fieldVal) .map(k => `${k}${delimiter}${fieldVal[k]}`) .join(delimiter); } else { return fieldName + '=' + fieldVal.toString(); } } function deepObjectEncodeField(fieldVal: any, fieldName: string): string { if (isArray(fieldVal)) { console.warn('deepObject style cannot be used with array value:' + fieldVal.toString()); return ''; } else if (typeof fieldVal === 'object') { return Object.keys(fieldVal) .map(k => `${fieldName}[${k}]=${fieldVal[k]}`) .join('&'); } else { console.warn('deepObject style cannot be used with non-object value:' + fieldVal.toString()); return ''; } } function serializeFormValue(name: string, explode: boolean, value: any) { // Use RFC6570 safe name ([a-zA-Z0-9_]) and replace with our name later // e.g. URI.template doesn't parse names with hyphen (-) which are valid query param names const safeName = '__redoc_param_name__'; const suffix = explode ? '*' : ''; const template = URLtemplate.parse(`{?${safeName}${suffix}}`); return template .expand({ [safeName]: value }) .substring(1) .replace(/__redoc_param_name__/g, name); } /* * Should be used only for url-form-encoded body payloads * To be used for parameters should be extended with other style values */ export function urlFormEncodePayload( payload: object, encoding: { [field: string]: OpenAPIEncoding } = {}, ) { if (isArray(payload)) { throw new Error('Payload must have fields: ' + payload.toString()); } else { return Object.keys(payload) .map(fieldName => { const fieldVal = payload[fieldName]; const { style = 'form', explode = true } = encoding[fieldName] || {}; switch (style) { case 'form': return serializeFormValue(fieldName, explode, fieldVal); case 'spaceDelimited': return delimitedEncodeField(fieldVal, fieldName, '%20'); case 'pipeDelimited': return delimitedEncodeField(fieldVal, fieldName, '|'); case 'deepObject': return deepObjectEncodeField(fieldVal, fieldName); default: // TODO implement rest of styles for path parameters console.warn('Incorrect or unsupported encoding style: ' + style); return ''; } }) .join('&'); } } function serializePathParameter( name: string, style: OpenAPIParameterStyle, explode: boolean, value: any, ): string { const suffix = explode ? '*' : ''; let prefix = ''; if (style === 'label') { prefix = '.'; } else if (style === 'matrix') { prefix = ';'; } // Use RFC6570 safe name ([a-zA-Z0-9_]) and replace with our name later // e.g. URI.template doesn't parse names with hyphen (-) which are valid query param names const safeName = '__redoc_param_name__'; const template = URLtemplate.parse(`{${prefix}${safeName}${suffix}}`); return template.expand({ [safeName]: value }).replace(/__redoc_param_name__/g, name); } function serializeQueryParameter( name: string, style: OpenAPIParameterStyle, explode: boolean, value: any, ): string { switch (style) { case 'form': return serializeFormValue(name, explode, value); case 'spaceDelimited': if (!isArray(value)) { console.warn('The style spaceDelimited is only applicable to arrays'); return ''; } if (explode) { return serializeFormValue(name, explode, value); } return `${name}=${value.join('%20')}`; case 'pipeDelimited': if (!isArray(value)) { console.warn('The style pipeDelimited is only applicable to arrays'); return ''; } if (explode) { return serializeFormValue(name, explode, value); } return `${name}=${value.join('|')}`; case 'deepObject': if (!explode || isArray(value) || typeof value !== 'object') { console.warn('The style deepObject is only applicable for objects with explode=true'); return ''; } return deepObjectEncodeField(value, name); default: console.warn('Unexpected style for query: ' + style); return ''; } } function serializeHeaderParameter( style: OpenAPIParameterStyle, explode: boolean, value: any, ): string { switch (style) { case 'simple': const suffix = explode ? '*' : ''; // name is not important here, so use RFC6570 safe name ([a-zA-Z0-9_]) const name = '__redoc_param_name__'; const template = URLtemplate.parse(`{${name}${suffix}}`); return decodeURIComponent(template.expand({ [name]: value })); default: console.warn('Unexpected style for header: ' + style); return ''; } } function serializeCookieParameter( name: string, style: OpenAPIParameterStyle, explode: boolean, value: any, ): string { switch (style) { case 'form': return serializeFormValue(name, explode, value); default: console.warn('Unexpected style for cookie: ' + style); return ''; } } export function serializeParameterValueWithMime(value: any, mime: string): string { if (isJsonLike(mime)) { return JSON.stringify(value); } else { console.warn(`Parameter serialization as ${mime} is not supported`); return ''; } } export function serializeParameterValue( parameter: (OpenAPIParameter & { serializationMime?: string }) | FieldModel, value: any, ): string { const { name, style, explode = false, serializationMime } = parameter; if (serializationMime) { switch (parameter.in) { case 'path': case 'header': return serializeParameterValueWithMime(value, serializationMime); case 'cookie': case 'query': return `${name}=${serializeParameterValueWithMime(value, serializationMime)}`; default: console.warn('Unexpected parameter location: ' + parameter.in); return ''; } } if (!style) { console.warn(`Missing style attribute or content for parameter ${name}`); return ''; } switch (parameter.in) { case 'path': return serializePathParameter(name, style, explode, value); case 'query': return serializeQueryParameter(name, style, explode, value); case 'header': return serializeHeaderParameter(style, explode, value); case 'cookie': return serializeCookieParameter(name, style, explode, value); default: console.warn('Unexpected parameter location: ' + parameter.in); return ''; } } export function getSerializedValue(field: FieldModel, example: any) { if (field.in) { // decode for better readability in examples: see https://github.com/Redocly/redoc/issues/1138 return decodeURIComponent(serializeParameterValue(field, example)); } else { return example; } } export function langFromMime(contentType: string): string { if (contentType.search(/xml/i) !== -1) { return 'xml'; } return 'clike'; } const DEFINITION_NAME_REGEX = /^#\/components\/(schemas|pathItems)\/([^/]+)$/; export function isNamedDefinition(pointer?: string): boolean { return DEFINITION_NAME_REGEX.test(pointer || ''); } export function getDefinitionName(pointer?: string): string | undefined { const [name] = pointer?.match(DEFINITION_NAME_REGEX)?.reverse() || []; return name; } function humanizeMultipleOfConstraint(multipleOf: number | undefined): string | undefined { if (multipleOf === undefined) { return; } const strigifiedMultipleOf = multipleOf.toString(10); if (!/^0\.0*1$/.test(strigifiedMultipleOf)) { return `multiple of ${strigifiedMultipleOf}`; } return `decimal places <= ${strigifiedMultipleOf.split('.')[1].length}`; } function humanizeRangeConstraint( description: string, min: number | undefined, max: number | undefined, ): string | undefined { let stringRange; if (min !== undefined && max !== undefined) { if (min === max) { stringRange = `${min} ${description}`; } else { stringRange = `[ ${min} .. ${max} ] ${description}`; } } else if (max !== undefined) { stringRange = `<= ${max} ${description}`; } else if (min !== undefined) { if (min === 1) { stringRange = 'non-empty'; } else { stringRange = `>= ${min} ${description}`; } } return stringRange; } export function humanizeNumberRange(schema: OpenAPISchema): string | undefined { const minimum = typeof schema.exclusiveMinimum === 'number' ? Math.min(schema.exclusiveMinimum, schema.minimum ?? Infinity) : schema.minimum; const maximum = typeof schema.exclusiveMaximum === 'number' ? Math.max(schema.exclusiveMaximum, schema.maximum ?? -Infinity) : schema.maximum; const exclusiveMinimum = typeof schema.exclusiveMinimum === 'number' || schema.exclusiveMinimum; const exclusiveMaximum = typeof schema.exclusiveMaximum === 'number' || schema.exclusiveMaximum; if (minimum !== undefined && maximum !== undefined) { return `${exclusiveMinimum ? '( ' : '[ '}${minimum} .. ${maximum}${ exclusiveMaximum ? ' )' : ' ]' }`; } else if (maximum !== undefined) { return `${exclusiveMaximum ? '< ' : '<= '}${maximum}`; } else if (minimum !== undefined) { return `${exclusiveMinimum ? '> ' : '>= '}${minimum}`; } } export function humanizeConstraints(schema: OpenAPISchema): string[] { const res: string[] = []; const stringRange = humanizeRangeConstraint('characters', schema.minLength, schema.maxLength); if (stringRange !== undefined) { res.push(stringRange); } const arrayRange = humanizeRangeConstraint('items', schema.minItems, schema.maxItems); if (arrayRange !== undefined) { res.push(arrayRange); } const multipleOfConstraint = humanizeMultipleOfConstraint(schema.multipleOf); if (multipleOfConstraint !== undefined) { res.push(multipleOfConstraint); } const numberRange = humanizeNumberRange(schema); if (numberRange !== undefined) { res.push(numberRange); } if (schema.uniqueItems) { res.push('unique'); } return res; } export function sortByRequired(fields: FieldModel[], order: string[] = []) { const unrequiredFields: FieldModel[] = []; const orderedFields: FieldModel[] = []; const unorderedFields: FieldModel[] = []; fields.forEach(field => { if (field.required) { order.includes(field.name) ? orderedFields.push(field) : unorderedFields.push(field); } else { unrequiredFields.push(field); } }); orderedFields.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)); return [...orderedFields, ...unorderedFields, ...unrequiredFields]; } export function sortByField( fields: FieldModel[], param: keyof Pick, ) { return [...fields].sort((a, b) => { return a[param].localeCompare(b[param]); }); } export function mergeParams( parser: OpenAPIParser, pathParams: Array> = [], operationParams: Array> = [], ): Array> { const operationParamNames = {}; operationParams.forEach(param => { param = parser.shallowDeref(param); operationParamNames[param.name + '_' + param.in] = true; }); // filter out path params overridden by operation ones with the same name pathParams = pathParams.filter(param => { param = parser.shallowDeref(param); return !operationParamNames[param.name + '_' + param.in]; }); return pathParams.concat(operationParams); } export function mergeSimilarMediaTypes( types: Record, ): Record { const mergedTypes = {}; Object.keys(types).forEach(name => { const mime = types[name]; // ignore content type parameters (e.g. charset) and merge const normalizedMimeName = name.split(';')[0].trim(); if (!mergedTypes[normalizedMimeName]) { mergedTypes[normalizedMimeName] = mime; return; } mergedTypes[normalizedMimeName] = { ...mergedTypes[normalizedMimeName], ...mime }; }); return mergedTypes; } export function expandDefaultServerVariables(url: string, variables: object = {}) { return url.replace( /(?:{)([\w-.]+)(?:})/g, (match, name) => (variables[name] && variables[name].default) || match, ); } export function normalizeServers( specUrl: string | undefined, servers: OpenAPIServer[], ): OpenAPIServer[] { const getHref = () => { if (!IS_BROWSER) { return ''; } const href = window.location.href; return href.endsWith('.html') ? dirname(href) : href; }; const baseUrl = specUrl === undefined ? removeQueryString(getHref()) : dirname(specUrl); if (servers.length === 0) { // Behaviour defined in OpenAPI spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#openapi-object servers = [ { url: '/', }, ]; } function normalizeUrl(url: string): string { return resolveUrl(baseUrl, url); } return servers.map(server => { return { ...server, url: normalizeUrl(server.url), description: server.description || '', }; }); } export const SECURITY_DEFINITIONS_COMPONENT_NAME = 'security-definitions'; export const SECURITY_DEFINITIONS_JSX_NAME = 'SecurityDefinitions'; export const SCHEMA_DEFINITION_JSX_NAME = 'SchemaDefinition'; export let SECURITY_SCHEMES_SECTION_PREFIX = 'section/Authentication/'; export function setSecuritySchemePrefix(prefix: string) { SECURITY_SCHEMES_SECTION_PREFIX = prefix; } export const shortenHTTPVerb = verb => ({ delete: 'del', options: 'opts', }[verb] || verb); export function isRedocExtension(key: string): boolean { const redocExtensions = { 'x-circular-ref': true, 'x-code-samples': true, // deprecated 'x-codeSamples': true, 'x-displayName': true, 'x-examples': true, 'x-ignoredHeaderParameters': true, 'x-logo': true, 'x-nullable': true, 'x-servers': true, 'x-tagGroups': true, 'x-traitTag': true, 'x-additionalPropertiesName': true, 'x-explicitMappingOnly': true, }; return key in redocExtensions; } export function extractExtensions( obj: object, showExtensions: string[] | true, ): Record { return Object.keys(obj) .filter(key => { if (showExtensions === true) { return key.startsWith('x-') && !isRedocExtension(key); } return key.startsWith('x-') && showExtensions.indexOf(key) > -1; }) .reduce((acc, key) => { acc[key] = obj[key]; return acc; }, {}); } export function pluralizeType(displayType: string): string { return displayType .split(' or ') .map(type => type.replace(/^(string|object|number|integer|array|boolean)s?( ?.*)/, '$1s$2')) .join(' or '); } export function getContentWithLegacyExamples( info: OpenAPIRequestBody | OpenAPIResponse, ): { [mime: string]: OpenAPIMediaType } | undefined { let mediaContent = info.content; const xExamples = info['x-examples']; // converted from OAS2 body param const xExample = info['x-example']; // converted from OAS2 body param if (xExamples) { mediaContent = { ...mediaContent }; for (const mime of Object.keys(xExamples)) { const examples = xExamples[mime]; mediaContent[mime] = { ...mediaContent[mime], examples, }; } } else if (xExample) { mediaContent = { ...mediaContent }; for (const mime of Object.keys(xExample)) { const example = xExample[mime]; mediaContent[mime] = { ...mediaContent[mime], example, }; } } return mediaContent; }