/**
* @copyright Copyright 2021 Kevin Locke <kevin@kevinlocke.name>
* @license MIT
* @module "openapi-transformers/remove-query-from-paths.js"
*/
import { METHODS } from 'node:http';
import { isDeepStrictEqual } from 'node:util';
import OpenApiTransformerBase from 'openapi-transformer-base';
import visit from 'openapi-transformer-base/visit.js';
const oasVersionSymbol = Symbol('openApiVersion');
const pathVarNameToParamsSymbol = Symbol('pathVarNameToParams');
const httpMethodSet = new Set(METHODS);
function isArrayEqual(array1, array2) {
if (array1 === undefined) {
array1 = [];
}
if (array2 === undefined) {
array2 = [];
}
return isDeepStrictEqual(array1, array2);
}
function valueToType(value) {
const type = typeof value;
if (value === undefined || type === 'symbol') {
throw new TypeError(`${type} type not available in JSON Schema`);
}
return type === 'number' && value % 1 === 0 ? 'integer'
: value === null ? 'null'
: type;
}
function createConstQueryParam(name, value) {
const oasVersion = this[oasVersionSymbol];
if (oasVersion.startsWith('2.')) {
return {
name,
in: 'query',
required: true,
type: valueToType(value),
enum: [value],
};
}
if (oasVersion.startsWith('3.0')) {
return {
name,
in: 'query',
required: true,
schema: {
enum: [value],
},
};
}
return {
name,
in: 'query',
required: true,
schema: {
const: value,
},
};
}
// FIXME: How to handle unpaired {}
// FIXME: How to handle = in {}
// FIXME: How to handle %-encoded characters in {}
// FIXME: How to handle missing variable name (e.g. /a/{}/b)
function parseQueryParams(query) {
const ampQuery = `&${query}`;
const paramValueRE =
/&([^{}&=]*(?:\{[^{}]*\}[^{}&=]*)*)(?:=([^{}&]*(?:\{[^{}]*\}[^{}&]*)*))?/y;
const paramMap = new Map();
let paramValueMatch;
while (paramValueRE.lastIndex < ampQuery.length
// eslint-disable-next-line no-cond-assign
&& (paramValueMatch = paramValueRE.exec(ampQuery)) !== null) {
if (paramValueMatch[0] === '&') {
// Nothing after &
continue;
}
const param = paramValueMatch[1];
const pvarStart = param.indexOf('{');
const pvarEnd = param.indexOf('}');
if (pvarStart > 0) {
if (pvarEnd > pvarStart) {
this.warn('Unable to handle query param with variable name', param);
} else {
this.warn('Unpaired "{" in query param name', param);
}
return undefined;
}
if (pvarEnd >= 0) {
this.warn('Unpaired "}" in query param name', param);
return undefined;
}
const value = paramValueMatch[2] || '';
const qvarStart = value.indexOf('{');
const qvarEnd = value.indexOf('}');
if (qvarStart > 0 || (qvarStart === 0 && qvarEnd !== value.length - 1)) {
this.warn(
'Unable to handle query value with constant and variable parts',
value,
);
return undefined;
}
if (qvarEnd < qvarStart) {
this.warn(
'Unpaired "%s" in query value',
qvarEnd >= 0 ? '}' : '{',
param,
);
return undefined;
}
// TODO: Convert to param with `type: array` and `explode: true`?
if (paramMap.has(param)) {
this.warn('Ignoring path with duplicate query parameter', param);
return undefined;
}
paramMap.set(param, value);
}
if (paramValueRE.lastIndex !== ampQuery.length) {
this.warn('Unable to parse query parameters', query);
return undefined;
}
return paramMap;
}
function combineParameters(opParams, pathParams) {
if (opParams === undefined) {
return pathParams;
}
if (!Array.isArray(opParams)) {
return opParams;
}
if (opParams.length === 0) {
return pathParams;
}
return [
...opParams,
...pathParams
// Don't include params from Path Item overridden by Operation
.filter((pp) => pp && !opParams.some((op) => op
&& op.name === pp.name
&& op.in === pp.in)),
];
}
function moveParameters(pathItem) {
const { parameters, ...newPathItem } = pathItem;
if (parameters === undefined
|| (Array.isArray(parameters) && parameters.length === 0)) {
return pathItem;
}
for (const [method, operation] of Object.entries(pathItem)) {
if (operation !== null
&& typeof operation === 'object'
&& httpMethodSet.has(method.toUpperCase())) {
newPathItem[method] = {
...operation,
parameters: combineParameters(operation.parameters, parameters),
};
}
}
return newPathItem;
}
function moveServers(pathItem) {
const { servers, ...newPathItem } = pathItem;
if (servers === undefined
|| (Array.isArray(servers) && servers.length === 0)) {
return pathItem;
}
for (const [method, operation] of Object.entries(pathItem)) {
if (operation !== null
&& typeof operation === 'object'
&& operation.servers === undefined
&& httpMethodSet.has(method.toUpperCase())) {
newPathItem[method] = {
...operation,
servers,
};
}
}
return newPathItem;
}
function mergePathItems(pathItem1, pathItem2) {
if (!isArrayEqual(pathItem1.parameters, pathItem2.parameters)) {
// TODO: Keep common parameters at Path Item level
pathItem1 = moveParameters(pathItem1);
pathItem2 = moveParameters(pathItem2);
}
if (!isArrayEqual(pathItem1.servers, pathItem2.servers)) {
pathItem1 = moveServers(pathItem1);
pathItem2 = moveServers(pathItem2);
}
const combined = { ...pathItem2 };
for (const [prop, value1] of Object.entries(pathItem1)) {
if (value1 !== undefined
&& prop !== 'parameters'
&& prop !== 'servers') {
const value2 = pathItem2[prop];
if (value2 !== undefined && !isDeepStrictEqual(value1, value2)) {
this.transformPath.push(prop);
this.warn(
'Refusing to overwrite %o with %o',
value2,
value1,
);
this.transformPath.pop();
return undefined;
}
combined[prop] = value1;
}
}
return combined;
}
function transformParameters(parameters) {
const pathVarNameToParams = this[pathVarNameToParamsSymbol];
return parameters.flatMap((param) => {
if (param === null
|| typeof param !== 'object'
|| param.in !== 'path') {
return param;
}
const newParams = pathVarNameToParams.get(param.name);
if (newParams === undefined) {
return param;
}
return newParams
.map((newParam) => ({ ...param, ...newParam }))
// If the parameter already exists, avoid duplicating it
.filter((newParam) => !parameters.some((p) => p
&& p !== param
&& p.name === newParam.name
&& p.in === newParam.in));
});
}
/**
* Transformer to remove query component of path in paths object.
*
* Including the query portion of a URL in path is not allowed in any current
* version of OpenAPI
* https://github.com/OAI/OpenAPI-Specification/issues/468#issuecomment-142393969
* but some authors include it for various reasons.
*
* Operations which would conflict with existing operations on the same path
* (excluding query parameters) are left unchanged.
* See QueriesToXMsPathsTransformer for another way to handle these.
*/
export default class RemoveQueryFromPathsTransformer
extends OpenApiTransformerBase {
constructor() {
super();
this[oasVersionSymbol] = undefined;
this[pathVarNameToParamsSymbol] = undefined;
}
transformOperation(operation) {
if (typeof operation !== 'object'
|| operation === null
|| Array.isArray(operation)) {
this.warn('Ignoring non-object Operation', operation);
return operation;
}
const { parameters } = operation;
if (!Array.isArray(parameters) || parameters.length === 0) {
if (parameters !== undefined && !Array.isArray(parameters)) {
this.warn('Ignoring non-Array parameters', parameters);
}
return operation;
}
return {
...operation,
parameters: visit(
this,
transformParameters,
'parameters',
parameters,
),
};
}
transformPathItem(pathItem) {
pathItem = super.transformPathItem(pathItem);
if (typeof pathItem !== 'object'
|| pathItem === null
|| Array.isArray(pathItem)) {
// Already warned by super.transformPathItem(pathItem)
return pathItem;
}
const { parameters } = pathItem;
if (!Array.isArray(parameters) || parameters.length === 0) {
// Already warned by super.transformPathItem(pathItem)
return pathItem;
}
return {
...pathItem,
parameters: visit(
this,
transformParameters,
'parameters',
parameters,
),
};
}
transformPaths(paths) {
if (typeof paths !== 'object' || paths === null || Array.isArray(paths)) {
this.warn('Ignoring non-object Paths', paths);
return paths;
}
// RegExp to match a path with a query part and optionally fragment part
// Careful to avoid matching ? or # in variable name as part of path
// (OAS 3.1.0 is clear that they are disallowed, but some path templating
// languages use ? in particular, so be prepared.)
const pathRE =
/^([^{?#]*(?:\{[^}]*\}[^{?#]*)*)\?([^{#]*(?:\{[^}]*\}[^{#]*)*)(#.*)?$/;
const pathMatches = Object.keys(paths)
.map((path) => pathRE.exec(path))
.filter(Boolean);
if (pathMatches.length === 0) {
return paths;
}
paths = { ...paths };
for (const [pathQuery, pathname, query, frag] of pathMatches) {
const srcPathItem = paths[pathQuery];
if (srcPathItem === undefined) {
continue;
}
if (srcPathItem === null
|| typeof srcPathItem !== 'object'
|| Array.isArray(srcPathItem)) {
this.transformPath.push(pathQuery);
this.warn('Ignoring non-object Path Item', srcPathItem);
this.transformPath.pop();
continue;
}
if (srcPathItem.parameters !== undefined
&& !Array.isArray(srcPathItem.parameters)) {
this.transformPath.push(pathQuery);
this.warn('Ignoring path with non-Array parameters', srcPathItem);
this.transformPath.pop();
continue;
}
const dstPath = pathname + (frag || '');
const dstPathItem = paths[dstPath];
if (dstPathItem === null
|| (dstPathItem !== undefined && typeof dstPathItem !== 'object')
|| Array.isArray(dstPathItem)) {
this.transformPath.push(dstPath);
this.warn(
'Refusing to overwrite non-object Path Item',
dstPathItem,
);
this.transformPath.pop();
continue;
}
let queryParams;
this.transformPath.push(pathQuery);
try {
queryParams = parseQueryParams(query);
} finally {
this.transformPath.pop();
}
if (queryParams === undefined) {
continue;
}
const constParameters = [];
const pathVarNameToParams = new Map();
for (const [name, value] of queryParams) {
if (value[0] !== '{') {
constParameters.push(createConstQueryParam.call(
this,
name,
value,
));
} else {
const valueName = value.slice(1, -1);
let params = pathVarNameToParams.get(valueName);
if (params === undefined) {
params = [];
if (dstPath.includes(value)) {
// parameter occurs in path and query parts
params.push({ name: valueName, in: 'path' });
}
pathVarNameToParams.set(valueName, params);
}
params.push({ name, in: 'query' });
}
}
let newPathItem = constParameters.length === 0 ? srcPathItem
: {
...srcPathItem,
parameters: combineParameters(
srcPathItem.parameters,
constParameters,
),
};
this[pathVarNameToParamsSymbol] = pathVarNameToParams;
try {
newPathItem = visit(
this,
this.transformPathItem,
pathQuery,
newPathItem,
);
} finally {
this[pathVarNameToParamsSymbol] = undefined;
}
const mergedPathItem = dstPathItem === undefined ? newPathItem : visit(
this,
mergePathItems,
dstPath,
dstPathItem,
newPathItem,
);
if (mergedPathItem !== undefined) {
paths[dstPath] = mergedPathItem;
delete paths[pathQuery];
}
}
return paths;
}
// Override as performance optimization, since only transforming paths
transformOpenApi(openApi) {
if (openApi === null
|| typeof openApi !== 'object'
|| Array.isArray(openApi)) {
this.warn('Ignoring non-object OpenAPI', openApi);
return openApi;
}
let oasVersion = '3.1.0';
if (openApi.openapi !== undefined) {
const openapi = `${openApi.openapi}`;
if (openapi.startsWith('3.')) {
oasVersion = openapi;
} else if (openapi === '3') {
oasVersion = '3.0.0';
} else {
this.warn('Unrecognized OpenAPI version', openApi.openapi);
}
} else if (openApi.swagger !== undefined) {
const swagger = `${openApi.swagger}`;
if (swagger === '2.0' || swagger === '2') {
oasVersion = '2.0';
} else {
this.warn('Unrecognized Swagger version', openApi.openapi);
}
} else {
this.warn('Document missing OpenAPI and Swagger version', openApi);
}
if (openApi.paths !== undefined) {
this[oasVersionSymbol] = oasVersion;
try {
openApi = {
...openApi,
paths: visit(
this,
this.transformPaths,
'paths',
openApi.paths,
),
};
} finally {
this[oasVersionSymbol] = undefined;
}
}
return openApi;
}
}