UNPKG

oas

Version:

Comprehensive tooling for working with OpenAPI definitions

786 lines (781 loc) 26.8 kB
import { Operation, Webhook } from "./chunk-HGHW4JSM.js"; import { findSchemaDefinition, supportedMethods } from "./chunk-W2TD4LSC.js"; import { isPrimitive } from "./chunk-5KFARTQ3.js"; import { CODE_SAMPLES, HEADERS, OAUTH_OPTIONS, PARAMETER_ORDERING, SAMPLES_LANGUAGES, extensionDefaults, getExtension, hasRootExtension, validateParameterOrdering } from "./chunk-L2OVXZK3.js"; // src/index.ts import { dereference } from "@readme/openapi-parser"; import { pathToRegexp, match } from "path-to-regexp"; // src/lib/get-auth.ts function getKey(user, scheme) { switch (scheme.type) { case "oauth2": case "apiKey": return user[scheme._key] || user.apiKey || scheme["x-default"] || null; case "http": if (scheme.scheme === "basic") { return user[scheme._key] || { user: user.user || null, pass: user.pass || null }; } if (scheme.scheme === "bearer") { return user[scheme._key] || user.apiKey || scheme["x-default"] || null; } return null; default: return null; } } function getByScheme(user, scheme = {}, selectedApp) { if (user?.keys && user.keys.length) { if (selectedApp) { return getKey( user.keys.find((key) => key.name === selectedApp), scheme ); } return getKey(user.keys[0], scheme); } return getKey(user, scheme); } function getAuth(api, user, selectedApp) { return Object.keys(api?.components?.securitySchemes || {}).map((scheme) => { return { [scheme]: getByScheme( user, { // This sucks but since we dereference we'll never have a `$ref` pointer here with a // `ReferenceObject` type. ...api.components.securitySchemes[scheme], _key: scheme }, selectedApp ) }; }).reduce((prev, next) => Object.assign(prev, next), {}); } // src/lib/get-user-variable.ts function getUserVariable(user, property, selectedApp) { let key = user; if ("keys" in user && Array.isArray(user.keys) && user.keys.length) { if (selectedApp) { key = user.keys.find((k) => k.name === selectedApp); } else { key = user.keys[0]; } } return key[property] || user[property] || null; } // src/index.ts var SERVER_VARIABLE_REGEX = /{([-_a-zA-Z0-9:.[\]]+)}/g; function ensureProtocol(url) { if (url.match(/^\/\//)) { return `https:${url}`; } if (!url.match(/\/\//)) { return `https://${url}`; } return url; } function stripTrailingSlash(url) { if (url[url.length - 1] === "/") { return url.slice(0, -1); } return url; } function normalizedUrl(api, selected) { const exampleDotCom = "https://example.com"; let url; try { url = api.servers[selected].url; if (!url) throw new Error("no url"); url = stripTrailingSlash(url); if (url.startsWith("/") && !url.startsWith("//")) { const urlWithOrigin = new URL(exampleDotCom); urlWithOrigin.pathname = url; url = urlWithOrigin.href; } } catch (e) { url = exampleDotCom; } return ensureProtocol(url); } function transformUrlIntoRegex(url) { return stripTrailingSlash(url.replace(SERVER_VARIABLE_REGEX, "([-_a-zA-Z0-9:.[\\]]+)")); } function normalizePath(path) { return path.replace(/({?){(.*?)}(}?)/g, (str, ...args) => { return `:${args[1].replace("-", "")}`; }).replace(/::/, "\\::").split("?")[0]; } function generatePathMatches(paths, pathName, origin) { const prunedPathName = pathName.split("?")[0]; return Object.keys(paths).map((path) => { const cleanedPath = normalizePath(path); let matchResult; try { const matchStatement = match(cleanedPath, { decode: decodeURIComponent }); matchResult = matchStatement(prunedPathName); } catch (err) { return; } const slugs = {}; if (matchResult && Object.keys(matchResult.params).length) { Object.keys(matchResult.params).forEach((param) => { slugs[`:${param}`] = matchResult.params[param]; }); } return { url: { origin, path: cleanedPath.replace(/\\::/, "::"), nonNormalizedPath: path, slugs }, operation: paths[path], match: matchResult }; }).filter(Boolean).filter((p) => p.match); } function filterPathMethods(pathMatches, targetMethod) { const regExp = pathToRegexp(targetMethod); return pathMatches.map((p) => { const captures = Object.keys(p.operation).filter((r) => regExp.regexp.exec(r)); if (captures.length) { const method = captures[0]; p.url.method = method.toUpperCase(); return { url: p.url, operation: p.operation[method] }; } return false; }).filter(Boolean); } function findTargetPath(pathMatches) { let minCount = Object.keys(pathMatches[0].url.slugs).length; let operation; for (let m = 0; m < pathMatches.length; m += 1) { const selection = pathMatches[m]; const paramCount = Object.keys(selection.url.slugs).length; if (paramCount <= minCount) { minCount = paramCount; operation = selection; } } return operation; } var Oas = class _Oas { /** * An OpenAPI API Definition. */ api; /** * The current user that we should use when pulling auth tokens from security schemes. */ user; /** * Internal storage array that the library utilizes to keep track of the times the * {@see Oas.dereference} has been called so that if you initiate multiple promises they'll all * end up returning the same data set once the initial dereference call completed. */ promises; /** * Internal storage array that the library utilizes to keep track of its `dereferencing` state so * it doesn't initiate multiple dereferencing processes. */ dereferencing; /** * @param oas An OpenAPI definition. * @param user The information about a user that we should use when pulling auth tokens from * security schemes. */ constructor(oas, user) { if (typeof oas === "string") { oas = JSON.parse(oas); } this.api = oas || {}; this.user = user || {}; this.promises = []; this.dereferencing = { processing: false, complete: false, circularRefs: [] }; } /** * This will initialize a new instance of the `Oas` class. This method is useful if you're using * Typescript and are attempting to supply an untyped JSON object into `Oas` as it will force-type * that object to an `OASDocument` for you. * * @param oas An OpenAPI definition. * @param user The information about a user that we should use when pulling auth tokens from * security schemes. */ static init(oas, user) { return new _Oas(oas, user); } /** * Retrieve the OpenAPI version that this API definition is targeted for. */ getVersion() { if (this.api.openapi) { return this.api.openapi; } throw new Error("Unable to recognize what specification version this API definition conforms to."); } /** * Retrieve the current OpenAPI API Definition. * */ getDefinition() { return this.api; } url(selected = 0, variables) { const url = normalizedUrl(this.api, selected); return this.replaceUrl(url, variables || this.defaultVariables(selected)).trim(); } variables(selected = 0) { let variables; try { variables = this.api.servers[selected].variables; if (!variables) throw new Error("no variables"); } catch (e) { variables = {}; } return variables; } defaultVariables(selected = 0) { const variables = this.variables(selected); const defaults = {}; Object.keys(variables).forEach((key) => { defaults[key] = getUserVariable(this.user, key) || variables[key].default || ""; }); return defaults; } splitUrl(selected = 0) { const url = normalizedUrl(this.api, selected); const variables = this.variables(selected); return url.split(/({.+?})/).filter(Boolean).map((part, i) => { const isVariable = part.match(/[{}]/); const value = part.replace(/[{}]/g, ""); const key = `${value}-${i}`; if (!isVariable) { return { type: "text", value, key }; } const variable = variables?.[value]; return { type: "variable", value, key, description: variable?.description, enum: variable?.enum }; }); } /** * With a fully composed server URL, run through our list of known OAS servers and return back * which server URL was selected along with any contained server variables split out. * * For example, if you have an OAS server URL of `https://{name}.example.com:{port}/{basePath}`, * and pass in `https://buster.example.com:3000/pet` to this function, you'll get back the * following: * * { selected: 0, variables: { name: 'buster', port: 3000, basePath: 'pet' } } * * Re-supplying this data to `oas.url()` should return the same URL you passed into this method. * * @param baseUrl A given URL to extract server variables out of. */ splitVariables(baseUrl) { const matchedServer = (this.api.servers || []).map((server, i) => { const rgx = transformUrlIntoRegex(server.url); const found = new RegExp(rgx).exec(baseUrl); if (!found) { return false; } const variables = {}; Array.from(server.url.matchAll(SERVER_VARIABLE_REGEX)).forEach((variable, y) => { variables[variable[1]] = found[y + 1]; }); return { selected: i, variables }; }).filter(Boolean); return matchedServer.length ? matchedServer[0] : false; } /** * Replace templated variables with supplied data in a given URL. * * There are a couple ways that this will utilize variable data: * * - Supplying a `variables` object. If this is supplied, this data will always take priority. * This incoming `variables` object can be two formats: * `{ variableName: { default: 'value' } }` and `{ variableName: 'value' }`. If the former is * present, that will take precedence over the latter. * - If the supplied `variables` object is empty or does not match the current template name, * we fallback to the data stored in `this.user` and attempt to match against that. * See `getUserVariable` for some more information on how this data is pulled from `this.user`. * * If no variables supplied match up with the template name, the template name will instead be * used as the variable data. * * @param url A URL to swap variables into. * @param variables An object containing variables to swap into the URL. */ replaceUrl(url, variables = {}) { return stripTrailingSlash( url.replace(SERVER_VARIABLE_REGEX, (original, key) => { if (key in variables) { const data = variables[key]; if (typeof data === "object") { if (!Array.isArray(data) && data !== null && "default" in data) { return data.default; } } else { return data; } } const userVariable = getUserVariable(this.user, key); if (userVariable) { return userVariable; } return original; }) ); } /** * Retrieve an Operation of Webhook class instance for a given path and method. * * @param path Path to lookup and retrieve. * @param method HTTP Method to retrieve on the path. */ operation(path, method, opts = {}) { let operation = { parameters: [] }; if (opts.isWebhook) { const api = this.api; if (api?.webhooks[path]?.[method]) { operation = api.webhooks[path][method]; return new Webhook(api, path, method, operation); } } if (this?.api?.paths?.[path]?.[method]) { operation = this.api.paths[path][method]; } return new Operation(this.api, path, method, operation); } findOperationMatches(url) { const { origin, hostname } = new URL(url); const originRegExp = new RegExp(origin, "i"); const { servers, paths } = this.api; let pathName; let targetServer; let matchedServer; if (!servers || !servers.length) { matchedServer = { url: "https://example.com" }; } else { matchedServer = servers.find((s) => originRegExp.exec(this.replaceUrl(s.url, s.variables || {}))); if (!matchedServer) { const hostnameRegExp = new RegExp(hostname); matchedServer = servers.find((s) => hostnameRegExp.exec(this.replaceUrl(s.url, s.variables || {}))); } } if (!matchedServer) { const matchedServerAndPath = servers.map((server) => { const rgx = transformUrlIntoRegex(server.url); const found = new RegExp(rgx).exec(url); if (!found) { return void 0; } return { matchedServer: server, pathName: url.split(new RegExp(rgx)).slice(-1).pop() }; }).filter(Boolean); if (!matchedServerAndPath.length) { return void 0; } pathName = matchedServerAndPath[0].pathName; targetServer = { ...matchedServerAndPath[0].matchedServer }; } else { targetServer = { ...matchedServer, url: this.replaceUrl(matchedServer.url, matchedServer.variables || {}) }; [, pathName] = url.split(new RegExp(targetServer.url, "i")); } if (pathName === void 0) return void 0; if (pathName === "") pathName = "/"; const annotatedPaths = generatePathMatches(paths, pathName, targetServer.url); if (!annotatedPaths.length) return void 0; return annotatedPaths; } /** * Discover an operation in an OAS from a fully-formed URL and HTTP method. Will return an object * containing a `url` object and another one for `operation`. This differs from `getOperation()` * in that it does not return an instance of the `Operation` class. * * @param url A full URL to look up. * @param method The cooresponding HTTP method to look up. */ findOperation(url, method) { const annotatedPaths = this.findOperationMatches(url); if (!annotatedPaths) { return void 0; } const matches = filterPathMethods(annotatedPaths, method); if (!matches.length) return void 0; return findTargetPath(matches); } /** * Discover an operation in an OAS from a fully-formed URL without an HTTP method. Will return an * object containing a `url` object and another one for `operation`. * * @param url A full URL to look up. */ findOperationWithoutMethod(url) { const annotatedPaths = this.findOperationMatches(url); if (!annotatedPaths) { return void 0; } return findTargetPath(annotatedPaths); } /** * Retrieve an operation in an OAS from a fully-formed URL and HTTP method. Differs from * `findOperation` in that while this method will return an `Operation` instance, * `findOperation()` does not. * * @param url A full URL to look up. * @param method The cooresponding HTTP method to look up. */ getOperation(url, method) { const op = this.findOperation(url, method); if (op === void 0) { return void 0; } return this.operation(op.url.nonNormalizedPath, method); } /** * Retrieve an operation in an OAS by an `operationId`. * * If an operation does not have an `operationId` one will be generated in place, using the * default behavior of `Operation.getOperationId()`, and then asserted against your query. * * Note that because `operationId`s are unique that uniqueness does include casing so the ID * you are looking for will be asserted as an exact match. * * @see {Operation.getOperationId()} * @param id The `operationId` to look up. */ getOperationById(id) { let found; Object.values(this.getPaths()).forEach((operations) => { if (found) return; found = Object.values(operations).find((operation) => operation.getOperationId() === id); }); if (found) { return found; } Object.entries(this.getWebhooks()).forEach(([, webhooks]) => { if (found) return; found = Object.values(webhooks).find((webhook) => webhook.getOperationId() === id); }); return found; } /** * With an object of user information, retrieve the appropriate API auth keys from the current * OAS definition. * * @see {@link https://docs.readme.com/docs/passing-data-to-jwt} * @param user User * @param selectedApp The user app to retrieve an auth key for. */ getAuth(user, selectedApp) { if (!this.api?.components?.securitySchemes) { return {}; } return getAuth(this.api, user, selectedApp); } /** * Returns the `paths` object that exists in this API definition but with every `method` mapped * to an instance of the `Operation` class. * * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#openapi-object} * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object} */ getPaths() { const paths = {}; Object.keys(this.api.paths ? this.api.paths : []).forEach((path) => { if (path.startsWith("x-")) { return; } paths[path] = {}; if ("$ref" in this.api.paths[path]) { this.api.paths[path] = findSchemaDefinition(this.api.paths[path].$ref, this.api); } Object.keys(this.api.paths[path]).forEach((method) => { if (!supportedMethods.includes(method)) return; paths[path][method] = this.operation(path, method); }); }); return paths; } /** * Returns the `webhooks` object that exists in this API definition but with every `method` * mapped to an instance of the `Webhook` class. * * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#openapi-object} * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object} */ getWebhooks() { const webhooks = {}; const api = this.api; Object.keys(api.webhooks ? api.webhooks : []).forEach((id) => { webhooks[id] = {}; Object.keys(api.webhooks[id]).forEach((method) => { webhooks[id][method] = this.operation(id, method, { isWebhook: true }); }); }); return webhooks; } /** * Return an array of all tag names that exist on this API definition. * * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#openapi-object} * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object} * @param setIfMissing If a tag is not present on an operation that operations path will be added * into the list of tags returned. */ getTags(setIfMissing = false) { const allTags = /* @__PURE__ */ new Set(); const oasTags = this.api.tags?.map((tag) => { return tag.name; }) || []; const disableTagSorting = getExtension("disable-tag-sorting", this.api); Object.entries(this.getPaths()).forEach(([path, operations]) => { Object.values(operations).forEach((operation) => { const tags = operation.getTags(); if (setIfMissing && !tags.length) { allTags.add(path); return; } tags.forEach((tag) => { allTags.add(tag.name); }); }); }); Object.entries(this.getWebhooks()).forEach(([path, webhooks]) => { Object.values(webhooks).forEach((webhook) => { const tags = webhook.getTags(); if (setIfMissing && !tags.length) { allTags.add(path); return; } tags.forEach((tag) => { allTags.add(tag.name); }); }); }); const endpointTags = []; const tagsArray = []; if (disableTagSorting) { return Array.from(allTags); } Array.from(allTags).forEach((tag) => { if (oasTags.includes(tag)) { tagsArray.push(tag); } else { endpointTags.push(tag); } }); let sortedTags = tagsArray.sort((a, b) => { return oasTags.indexOf(a) - oasTags.indexOf(b); }); sortedTags = sortedTags.concat(endpointTags); return sortedTags; } /** * Determine if a given a custom specification extension exists within the API definition. * * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions} * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#specification-extensions} * @param extension Specification extension to lookup. */ hasExtension(extension) { return hasRootExtension(extension, this.api); } /** * Retrieve a custom specification extension off of the API definition. * * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions} * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#specification-extensions} * @param extension Specification extension to lookup. */ getExtension(extension, operation) { return getExtension(extension, this.api, operation); } /** * Determine if a given OpenAPI custom extension is valid or not. * * @see {@link https://docs.readme.com/docs/openapi-extensions} * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions} * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#specification-extensions} * @param extension Specification extension to validate. * @throws */ validateExtension(extension) { if (this.hasExtension("x-readme")) { const data = this.getExtension("x-readme"); if (typeof data !== "object" || Array.isArray(data) || data === null) { throw new TypeError('"x-readme" must be of type "Object"'); } if (extension in data) { if ([CODE_SAMPLES, HEADERS, PARAMETER_ORDERING, SAMPLES_LANGUAGES].includes(extension)) { if (!Array.isArray(data[extension])) { throw new TypeError(`"x-readme.${extension}" must be of type "Array"`); } if (extension === PARAMETER_ORDERING) { validateParameterOrdering(data[extension], `x-readme.${extension}`); } } else if (extension === OAUTH_OPTIONS) { if (typeof data[extension] !== "object") { throw new TypeError(`"x-readme.${extension}" must be of type "Object"`); } } else if (typeof data[extension] !== "boolean") { throw new TypeError(`"x-readme.${extension}" must be of type "Boolean"`); } } } if (this.hasExtension(`x-${extension}`)) { const data = this.getExtension(`x-${extension}`); if ([CODE_SAMPLES, HEADERS, PARAMETER_ORDERING, SAMPLES_LANGUAGES].includes(extension)) { if (!Array.isArray(data)) { throw new TypeError(`"x-${extension}" must be of type "Array"`); } if (extension === PARAMETER_ORDERING) { validateParameterOrdering(data, `x-${extension}`); } } else if (extension === OAUTH_OPTIONS) { if (typeof data !== "object") { throw new TypeError(`"x-${extension}" must be of type "Object"`); } } else if (typeof data !== "boolean") { throw new TypeError(`"x-${extension}" must be of type "Boolean"`); } } } /** * Validate all of our custom or known OpenAPI extensions, throwing exceptions when necessary. * * @see {@link https://docs.readme.com/docs/openapi-extensions} * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions} * @see {@link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#specification-extensions} */ validateExtensions() { Object.keys(extensionDefaults).forEach((extension) => { this.validateExtension(extension); }); } /** * Retrieve any circular `$ref` pointers that maybe present within the API definition. * * This method requires that you first dereference the definition. * * @see Oas.dereference */ getCircularReferences() { if (!this.dereferencing.complete) { throw new Error("#dereference() must be called first in order for this method to obtain circular references."); } return this.dereferencing.circularRefs; } /** * Dereference the current OAS definition so it can be parsed free of worries of `$ref` schemas * and circular structures. * */ async dereference(opts = { preserveRefAsJSONSchemaTitle: false }) { if (this.dereferencing.complete) { return new Promise((resolve) => { resolve(true); }); } if (this.dereferencing.processing) { return new Promise((resolve, reject) => { this.promises.push({ resolve, reject }); }); } this.dereferencing.processing = true; const { api, promises } = this; if (api.components && api.components.schemas && typeof api.components.schemas === "object") { Object.keys(api.components.schemas).forEach((schemaName) => { if (isPrimitive(api.components.schemas[schemaName]) || Array.isArray(api.components.schemas[schemaName]) || api.components.schemas[schemaName] === null) { return; } if (opts.preserveRefAsJSONSchemaTitle) { api.components.schemas[schemaName].title = schemaName; } api.components.schemas[schemaName]["x-readme-ref-name"] = schemaName; }); } const circularRefs = /* @__PURE__ */ new Set(); return dereference(api, { resolve: { // We shouldn't be resolving external pointers at this point so just ignore them. external: false }, dereference: { // If circular `$refs` are ignored they'll remain in the OAS as `$ref: String`, otherwise // `$ref‘ just won't exist. This allows us to do easy circular reference detection. circular: "ignore", onCircular: (path) => { circularRefs.add(`#${path.split("#")[1]}`); } } }).then((dereferenced) => { this.api = dereferenced; this.promises = promises; this.dereferencing = { processing: false, complete: true, // We need to convert our `Set` to an array in order to match the typings. circularRefs: [...circularRefs] }; if (opts.cb) { opts.cb(); } }).then(() => { return this.promises.map((deferred) => deferred.resolve()); }); } }; export { Oas }; //# sourceMappingURL=chunk-36L32E7W.js.map