oas
Version:
Comprehensive tooling for working with OpenAPI definitions
584 lines (583 loc) • 21.9 kB
JavaScript
import {
query
} from "../chunk-CKC36IL7.js";
import {
decodePointer,
supportedMethods
} from "../chunk-65ZD6K3I.js";
import "../chunk-S27IGTVG.js";
import {
isOpenAPI31,
isRef
} from "../chunk-PSNTODZL.js";
// src/reducer/index.ts
import jsonPointer from "jsonpointer";
var OpenAPIReducer = class _OpenAPIReducer {
definition;
/**
* A collection of `$ref` pointers that are used within our reduced API definition. This is used
* to ensure that all referenced schemas are retained in our resulting API definition. Not
* retaining them would result in an invalid OpenAPI definition.
*/
$refs = /* @__PURE__ */ new Set();
/**
* A collection of OpenAPI tags that are used within our reduced API definition.
*/
usedTags = /* @__PURE__ */ new Set();
/**
* A collection of OpenAPI paths and operations that are cross-referenced from any other paths
* and operations that we are reducing. This collection is used in order to ensure that those
* schemas are retained with our resulting API definition. Not retaining them would result in an
* invalid OpenAPI definition.
*/
retainPathMethods = /* @__PURE__ */ new Set();
/**
* A collection of OpenAPI webhook names and methods that are cross-referenced from any other
* schemas. This collection, like `retainPathMethods`, is used in order to ensure that those
* schemas are retained with our resulting API definition. Not retaining them would result in an
* invalid OpenAPI definition.
*/
retainWebhookMethods = /* @__PURE__ */ new Set();
/**
* An array of OpenAPI tags to reduce down to.
*/
tagsToReduceBy = [];
/**
* A collection of OpenAPI paths and operations to reduce down to.
*/
pathsToReduceBy = {};
/**
* A collection of OpenAPI webhooks to reduce down to.
*/
webhooksToReduceBy = {};
hasTagsToReduceBy = false;
hasPathsToReduceBy = false;
hasWebhooksToReduceBy = false;
constructor(definition) {
this.definition = structuredClone(definition);
}
/**
* Initialize a new instance of the `OpenAPIReducer`. The reducer allows you to reduce an OpenAPI
* definition down to only the information necessary to fulfill a specific set of tags, paths,
* operations, and webhooks.
*
* OpenAPI reduction can be helpful not only to isolate and troubleshoot issues with large API
* definitions, but also to compress a large API definition down to a manageable size containing
* a specific set of items.
*
* All OpenAPI definitions reduced will still be fully functional and valid OpenAPI definitions.
*
* @param definition An OpenAPI definition to reduce.
*/
static init(definition) {
return new _OpenAPIReducer(definition);
}
/**
* Mark an OpenAPI tag to be included in our reduced API definition. Tag casing does not matter.
*
* @param tag The tag to mark for reduction.
*/
byTag(tag) {
this.tagsToReduceBy.push(tag.toLowerCase());
return this;
}
/**
* Mark an entire OpenAPI path, and all methods that it contains, to be included in your reduced
* API definition. Path casing does not matter.
*
* @param path The path to mark for reduction.
*/
byPath(path) {
this.pathsToReduceBy[path.toLowerCase()] = "*";
return this;
}
/**
* Mark a single OpenAPI operation to be included in your reduced API definition. If the path
* that this operation is a part of utilizes common parameters, those will be automatically
* included. Path and method casing does not matter.
*
* Note that if you previously called `.byPath()` to reduce an entire path down, calling
* `.byOperation()` will override that to just reduce this specific method (or this plus
* subsequent calls to `.byOperation()`).
*
* @param path The path that the operation is a part of.
* @param method The HTTP method of the operation to mark for reduction.
*
*/
byOperation(path, method) {
const pathLC = path.toLowerCase();
const methodLC = method.toLowerCase();
if (this.pathsToReduceBy[pathLC] && Array.isArray(this.pathsToReduceBy[pathLC])) {
this.pathsToReduceBy[pathLC].push(methodLC);
} else {
this.pathsToReduceBy[pathLC] = [methodLC];
}
return this;
}
byWebhook(webhookName, method) {
const nameLC = webhookName.toLowerCase();
if (!method) {
this.webhooksToReduceBy[nameLC] = "*";
return this;
}
const methodLC = method.toLowerCase();
if (this.webhooksToReduceBy[nameLC] && Array.isArray(this.webhooksToReduceBy[nameLC])) {
this.webhooksToReduceBy[nameLC].push(methodLC);
} else {
this.webhooksToReduceBy[nameLC] = [methodLC];
}
return this;
}
/**
* Reduce the current OpenAPI definition down to the configured filters.
*
*/
reduce() {
if (!this.definition.openapi) {
throw new Error("Sorry, only OpenAPI definitions are supported.");
}
this.hasPathsToReduceBy = Boolean(Object.keys(this.pathsToReduceBy).length);
this.hasWebhooksToReduceBy = Boolean(Object.keys(this.webhooksToReduceBy).length);
this.hasTagsToReduceBy = Boolean(this.tagsToReduceBy.length);
if ("security" in this.definition) {
Object.values(this.definition.security || {}).forEach((sec) => {
Object.keys(sec).forEach((scheme) => {
this.$refs.add(`#/components/securitySchemes/${scheme}`);
});
});
}
this.walkPaths();
this.walkWebhooks();
this.$refs.forEach(($ref) => {
this.accumulateUsedRefs(this.definition, this.$refs, $ref);
});
this.$refs.forEach((ref) => {
const usedPathRef = this.parsePathRef(ref);
if (usedPathRef) {
this.retainPathMethods.add(`${usedPathRef.path.toLowerCase()}|${usedPathRef.method.toLowerCase()}`);
}
const usedWebhookRef = this.parseWebhookRef(ref);
if (usedWebhookRef) {
this.retainWebhookMethods.add(`${usedWebhookRef.name.toLowerCase()}|${usedWebhookRef.method.toLowerCase()}`);
}
});
this.reducePaths();
this.reduceWebhooks();
const hasPaths = Boolean(this.definition.paths && Object.keys(this.definition.paths).length);
const hasWebhooks = Boolean(
"webhooks" in this.definition && this.definition.webhooks && Object.keys(this.definition.webhooks).length
);
if (!hasPaths && !hasWebhooks) {
throw new Error(
"All paths and webhooks in the API definition were removed. Did you supply the right path, operation, or webhook to reduce by?"
);
}
if ("components" in this.definition) {
Object.keys(this.definition.components || {}).forEach((componentType) => {
Object.keys(this.definition.components?.[componentType] || {}).forEach((component) => {
const refIsUsed = this.$refs.has(`#/components/${componentType}/${component}`) || Array.from(this.$refs).some((ref) => {
return ref.startsWith(`#/components/${componentType}/${component}/`);
});
if (!refIsUsed) {
delete this.definition.components?.[componentType]?.[component];
}
});
if (!Object.keys(this.definition.components?.[componentType] || {}).length) {
delete this.definition.components?.[componentType];
}
});
if (!Object.keys(this.definition.components || {}).length) {
delete this.definition.components;
}
}
if ("tags" in this.definition) {
this.definition.tags = (this.definition.tags ?? []).filter((tag) => {
return Boolean(tag) && this.usedTags.has(tag.name);
});
if (!this.definition.tags?.length) {
delete this.definition.tags;
}
}
return this.definition;
}
/**
* Recursively process a `$ref` pointer and accumulate any other `$ref` pointers that it or its
* children use. This handles circular references by skipping `$ref` pointers we have already seen.
* Additionally when a `$ref` points to `#/paths` we record the used path + method so we can
* retain cross-operation references within the reduced definition.
*
* @param schema JSON Schema object to look for and accumulate any `$ref` pointers that it may have.
* @param $refs Known set of `$ref` pointers.
* @param $ref `$ref` pointer to fetch a schema from out of the supplied schema.
*/
accumulateUsedRefs(schema, $refs, $ref) {
const pathRef = this.parsePathRef($ref);
if (pathRef) {
this.retainPathMethods.add(`${pathRef.path.toLowerCase()}|${pathRef.method.toLowerCase()}`);
}
const webhookRef = this.parseWebhookRef($ref);
if (webhookRef) {
this.retainWebhookMethods.add(`${webhookRef.name.toLowerCase()}|${webhookRef.method.toLowerCase()}`);
}
let $refSchema;
if (typeof $ref === "string") $refSchema = jsonPointer.get(schema, $ref.substring(1));
if ($refSchema === void 0) {
return;
}
this.queryForRefPointers($refSchema).forEach(({ value: currRef }) => {
const foundRef = this.toRefString(currRef);
if (!foundRef) {
return;
}
if ($refs.has(foundRef)) {
return;
}
$refs.add(foundRef);
this.accumulateUsedRefs(schema, $refs, foundRef);
});
}
/**
* Query a JSON Schema object for any `$ref` pointers using JSONPath and return any pointers that
* exist.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc9535}
* @param schema JSON Schema object to look for any `$ref` pointers within it.
*/
queryForRefPointers(schema) {
return query(["$..['$ref']"], schema);
}
/**
* Normalize a value from a `jsonpath-plus` `$ref` query to a `$ref` pointer because JSONPath
* queries may return the property value or the parent.
*
*/
toRefString(value) {
if (typeof value === "string") {
return value;
} else if (value && typeof value === "object" && "$ref" in value && typeof value.$ref === "string") {
return value.$ref;
}
return null;
}
/**
* If the given `$ref` points into a path (e.g. `#/paths/~1anything/post/...`), return the path
* and method so the reducer can ultimately retain cross-operation references.
*
*/
parsePathRef($ref) {
if (typeof $ref !== "string" || !$ref.startsWith("#/paths/")) {
return null;
}
const match = $ref.match(/^#\/paths\/([^/]+)\/([^/]+)(?:\/|$)/);
if (match) {
const pathSegment = match[1];
const method = match[2];
if (pathSegment && method) {
return { path: decodePointer(pathSegment), method };
}
}
return null;
}
/**
* If the given `$ref` points into webhooks (e.g. `#/webhooks/newBooking/post/...`), return the
* webhook name and method so the reducer can retain cross-referenced webhook operations.
*
*/
parseWebhookRef($ref) {
if (typeof $ref !== "string" || !$ref.startsWith("#/webhooks/")) {
return null;
}
const match = $ref.match(/^#\/webhooks\/([^/]+)\/([^/]+)(?:\/|$)/);
if (match) {
const webhookName = match[1];
const method = match[2];
if (webhookName && method) {
return { name: decodePointer(webhookName), method };
}
}
return null;
}
/**
* Walk through the `paths` in our OpenAPI definition and reduce down any that we know we do not
* want to keep and accumulate any `$ref` pointers that we find that may be cross-referenced in
* paths, webhooks, operations, and schemas that we _do_ want to keep.
*
*/
walkPaths() {
if (!("paths" in this.definition) || !this.definition.paths) {
return;
}
Object.keys(this.definition.paths).forEach((path) => {
const pathLC = path.toLowerCase();
if (this.hasWebhooksToReduceBy && !this.hasPathsToReduceBy) {
delete this.definition.paths?.[path];
return;
}
if (this.hasPathsToReduceBy) {
if (!(pathLC in this.pathsToReduceBy)) {
delete this.definition.paths?.[path];
return;
}
}
Object.keys(this.definition.paths?.[path] || {}).forEach((method) => {
if (method === "parameters" || !supportedMethods.includes(method.toLowerCase())) {
return;
}
if (this.hasPathsToReduceBy) {
if (this.pathsToReduceBy[pathLC] !== "*" && Array.isArray(this.pathsToReduceBy[pathLC]) && !this.pathsToReduceBy[pathLC].includes(method.toLowerCase())) {
return;
}
}
const operation = this.definition.paths?.[path]?.[method];
if (!operation) {
throw new Error(`Operation \`${method} ${path}\` not found`);
}
if (this.hasTagsToReduceBy) {
if (!(operation.tags || []).filter((tag) => this.tagsToReduceBy.includes(tag.toLowerCase())).length) {
return;
}
}
(operation.tags || []).forEach((tag) => {
this.usedTags.add(tag);
});
const pathLevelParams = this.definition.paths?.[path]?.parameters;
if (pathLevelParams) {
this.queryForRefPointers(pathLevelParams).forEach(({ value: ref }) => {
const refStr = this.toRefString(ref);
if (!refStr) {
return;
}
this.$refs.add(refStr);
this.accumulateUsedRefs(this.definition, this.$refs, refStr);
});
}
this.queryForRefPointers(operation).forEach(({ value: ref }) => {
const refStr = this.toRefString(ref);
if (!refStr) {
return;
}
this.$refs.add(refStr);
const pathRef = this.parsePathRef(refStr);
if (pathRef) {
this.retainPathMethods.add(`${pathRef.path.toLowerCase()}|${pathRef.method.toLowerCase()}`);
}
this.accumulateUsedRefs(this.definition, this.$refs, refStr);
});
Object.values(operation.security || {}).forEach((sec) => {
Object.keys(sec).forEach((scheme) => {
this.$refs.add(`#/components/securitySchemes/${scheme}`);
});
});
});
});
}
/**
* Walk through the `webhooks` in our OpenAPI definition and reduce down any that we know we do
* not want to keep and accumulate any `$ref` pointers that we find that may be cross-referenced
* in paths, operations, and schemas that we _do_ want to keep.
*
*/
walkWebhooks() {
if (!isOpenAPI31(this.definition)) {
return;
} else if (!("webhooks" in this.definition) || !this.definition.webhooks) {
return;
}
const definition = this.definition;
Object.keys(definition.webhooks || {}).forEach((webhookName) => {
const nameLC = webhookName.toLowerCase();
if (this.hasWebhooksToReduceBy && !(nameLC in this.webhooksToReduceBy)) {
return;
}
const webhook = definition.webhooks?.[webhookName];
if (!webhook || typeof webhook !== "object") {
return;
}
Object.keys(webhook).forEach((method) => {
if (method === "parameters" || !supportedMethods.includes(method.toLowerCase())) {
return;
}
if (this.hasWebhooksToReduceBy) {
const methodFilter = this.webhooksToReduceBy[nameLC];
if (methodFilter !== "*" && Array.isArray(methodFilter) && !methodFilter.includes(method.toLowerCase())) {
return;
}
}
if (isRef(webhook)) {
return;
}
const operation = webhook[method];
if (!operation) {
return;
}
if (this.hasTagsToReduceBy) {
if (!(operation.tags || []).filter((tag) => this.tagsToReduceBy.includes(tag.toLowerCase())).length) {
return;
}
}
(operation.tags || []).forEach((tag) => {
this.usedTags.add(tag);
});
if (webhook.parameters) {
this.queryForRefPointers(webhook.parameters).forEach(({ value: ref }) => {
const refStr = this.toRefString(ref);
if (!refStr) {
return;
}
this.$refs.add(refStr);
this.accumulateUsedRefs(definition, this.$refs, refStr);
});
}
this.queryForRefPointers(operation).forEach(({ value: ref }) => {
const refStr = this.toRefString(ref);
if (!refStr) {
return;
}
this.$refs.add(refStr);
const pathRef = this.parsePathRef(refStr);
if (pathRef) {
this.retainPathMethods.add(`${pathRef.path.toLowerCase()}|${pathRef.method.toLowerCase()}`);
}
const webhookRef = this.parseWebhookRef(refStr);
if (webhookRef) {
this.retainWebhookMethods.add(`${webhookRef.name.toLowerCase()}|${webhookRef.method.toLowerCase()}`);
}
this.accumulateUsedRefs(definition, this.$refs, refStr);
});
Object.values(operation.security || {}).forEach((sec) => {
Object.keys(sec).forEach((scheme) => {
this.$refs.add(`#/components/securitySchemes/${scheme}`);
});
});
});
});
}
/**
* Prune back our `paths` object in the OpenAPI definition to only include paths that we want to
* preserve.
*
*/
reducePaths() {
if (!("paths" in this.definition) || !this.definition.paths) {
return;
}
Object.keys(this.definition.paths).forEach((path) => {
const pathLC = path.toLowerCase();
if (this.hasPathsToReduceBy && !(pathLC in this.pathsToReduceBy)) {
delete this.definition.paths?.[path];
return;
}
Object.keys(this.definition.paths?.[path] || {}).forEach((method) => {
const methodLC = method.toLowerCase();
if (method === "parameters" || !supportedMethods.includes(methodLC)) {
return;
}
const retainedByRef = this.retainPathMethods.has(`${pathLC}|${methodLC}`) || Array.from(this.$refs).some((ref) => {
const pathRef = this.parsePathRef(ref);
return pathRef?.path.toLowerCase() === pathLC && pathRef?.method.toLowerCase() === methodLC;
});
if (methodLC !== "parameters") {
if (this.hasPathsToReduceBy) {
if (!retainedByRef && this.pathsToReduceBy[pathLC] !== "*" && Array.isArray(this.pathsToReduceBy[pathLC]) && !this.pathsToReduceBy[pathLC].includes(methodLC)) {
delete this.definition.paths?.[path]?.[method];
return;
}
}
}
const operation = this.definition.paths?.[path]?.[method];
if (!operation) {
throw new Error(`Operation \`${method} ${path}\` not found`);
}
if (this.hasTagsToReduceBy) {
if (!(operation.tags || []).filter((tag) => this.tagsToReduceBy.includes(tag.toLowerCase())).length) {
if (!retainedByRef) {
delete this.definition.paths?.[path]?.[method];
}
return;
}
}
if ("tags" in operation) {
operation.tags?.forEach((tag) => {
this.usedTags.add(tag);
});
}
if ("security" in operation) {
Object.values(operation.security || {}).forEach((sec) => {
Object.keys(sec).forEach((scheme) => {
this.$refs.add(`#/components/securitySchemes/${scheme}`);
});
});
}
});
if (!Object.keys(this.definition.paths?.[path] || {}).length) {
delete this.definition.paths?.[path];
}
});
if (!Object.keys(this.definition.paths || {}).length) {
if (!(this.definition.webhooks && Object.keys(this.definition.webhooks).length)) {
throw new Error(
"All paths in the API definition were removed. Did you supply the right path name to reduce by?"
);
}
delete this.definition.paths;
}
}
/**
* Prune back our `webhooks` object in the OpenAPI definition to only include webhooks that we
* want to preserve.
*
*/
reduceWebhooks() {
if (!isOpenAPI31(this.definition)) {
return;
} else if (!("webhooks" in this.definition) || !this.definition.webhooks) {
return;
}
const definition = this.definition;
Object.keys(definition.webhooks || {}).forEach((webhookName) => {
const nameLC = webhookName.toLowerCase();
if (this.hasWebhooksToReduceBy && !(nameLC in this.webhooksToReduceBy)) {
const retainedByRef = Array.from(this.retainWebhookMethods).some(
(key) => key.startsWith(`${nameLC}|`) || key === `${nameLC}|`
);
if (!retainedByRef) {
delete definition.webhooks?.[webhookName];
return;
}
}
const webhook = definition.webhooks?.[webhookName];
if (!webhook || typeof webhook !== "object") {
return;
}
if (isRef(webhook)) {
return;
}
Object.keys(webhook).forEach((method) => {
const methodLC = method.toLowerCase();
if (method === "parameters" || !supportedMethods.includes(methodLC)) {
return;
}
const retainedByRef = this.retainWebhookMethods.has(`${nameLC}|${methodLC}`);
if (this.hasWebhooksToReduceBy && !retainedByRef) {
const methodFilter = this.webhooksToReduceBy[nameLC];
if (methodFilter !== "*" && Array.isArray(methodFilter) && !methodFilter.includes(methodLC)) {
if (!definition.webhooks?.[webhookName] || isRef(definition.webhooks?.[webhookName])) {
return;
}
delete definition.webhooks?.[webhookName]?.[method];
}
}
});
if (!Object.keys(definition.webhooks?.[webhookName] || {}).length) {
delete definition.webhooks?.[webhookName];
}
});
if (definition.webhooks && !Object.keys(definition.webhooks).length) {
delete definition.webhooks;
}
}
};
export {
OpenAPIReducer
};
//# sourceMappingURL=index.js.map