UNPKG

docusaurus-plugin-openapi-docs

Version:

OpenAPI plugin for Docusaurus.

715 lines (646 loc) 22.4 kB
/* ============================================================================ * Copyright (c) Palo Alto Networks * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * ========================================================================== */ import path from "path"; import { Globby, GlobExcludeDefault, posixPath } from "@docusaurus/utils"; import chalk from "chalk"; import fs from "fs-extra"; import cloneDeep from "lodash/cloneDeep"; import kebabCase from "lodash/kebabCase"; import unionBy from "lodash/unionBy"; import uniq from "lodash/uniq"; import Converter from "openapi-to-postmanv2"; import { Collection } from "postman-collection"; import * as sdk from "postman-collection"; import { sampleRequestFromSchema } from "./createRequestExample"; import { OpenApiObject, TagGroupObject, TagObject } from "./types"; import { isURL } from "../index"; import { ApiMetadata, APIOptions, ApiPageMetadata, InfoPageMetadata, SchemaPageMetadata, SidebarOptions, TagPageMetadata, } from "../types"; import { sampleResponseFromSchema } from "./createResponseExample"; import { loadAndResolveSpec } from "./utils/loadAndResolveSpec"; /** * Convenience function for converting raw JSON to a Postman Collection object. */ function jsonToCollection(data: OpenApiObject): Promise<Collection> { return new Promise((resolve, reject) => { let schemaPack = new Converter.SchemaPack( { type: "json", data }, { schemaFaker: false } ); schemaPack.computedOptions.schemaFaker = false; schemaPack.convert((_err: any, conversionResult: any) => { if (!conversionResult.result) { return reject(conversionResult.reason); } return resolve(new sdk.Collection(conversionResult.output[0].data)); }); }); } /** * Creates a Postman Collection object from an OpenAPI definition. */ async function createPostmanCollection( openapiData: OpenApiObject ): Promise<Collection> { // Create copy of openapiData const data = cloneDeep(openapiData) as OpenApiObject; // Including `servers` breaks postman, so delete all of them. delete data.servers; for (let pathItemObject of Object.values(data.paths)) { delete pathItemObject.servers; delete pathItemObject.get?.servers; delete pathItemObject.put?.servers; delete pathItemObject.post?.servers; delete pathItemObject.delete?.servers; delete pathItemObject.options?.servers; delete pathItemObject.head?.servers; delete pathItemObject.patch?.servers; delete pathItemObject.trace?.servers; } return await jsonToCollection(data); } type PartialPage<T> = Omit<T, "permalink" | "source" | "sourceDirName">; function createItems( openapiData: OpenApiObject, options: APIOptions, sidebarOptions: SidebarOptions ): ApiMetadata[] { // TODO: Find a better way to handle this let items: PartialPage<ApiMetadata>[] = []; const infoIdSpaces = openapiData.info.title.replace(" ", "-").toLowerCase(); const infoId = kebabCase(infoIdSpaces); if (openapiData.info.description || openapiData.info.title) { // Only create an info page if we have a description. const infoDescription = openapiData.info?.description; let splitDescription: any; if (infoDescription) { splitDescription = infoDescription.match(/[^\r\n]+/g); } const infoPage: PartialPage<InfoPageMetadata> = { type: "info", id: infoId, unversionedId: infoId, title: openapiData.info.title ? openapiData.info.title.replace(/((?:^|[^\\])(?:\\{2})*)"/g, "$1'") : "", description: openapiData.info.description ? openapiData.info.description.replace( /((?:^|[^\\])(?:\\{2})*)"/g, "$1'" ) : "", frontMatter: { description: splitDescription ? splitDescription[0] .replace(/((?:^|[^\\])(?:\\{2})*)"/g, "$1'") .replace(/\s+$/, "") : "", }, securitySchemes: openapiData.components?.securitySchemes, info: { ...openapiData.info, tags: openapiData.tags, title: openapiData.info.title ?? "Introduction", logo: openapiData.info["x-logo"]! as any, darkLogo: openapiData.info["x-dark-logo"]! as any, }, }; items.push(infoPage); } for (let [path, pathObject] of Object.entries(openapiData.paths)) { const { $ref, description, parameters, servers, summary, ...rest } = pathObject; for (let [method, operationObject] of Object.entries({ ...rest })) { const title = operationObject.summary ?? operationObject.operationId ?? "Missing summary"; if (operationObject.description === undefined) { operationObject.description = operationObject.summary ?? operationObject.operationId ?? ""; } const baseId = operationObject.operationId ? kebabCase(operationObject.operationId) : kebabCase(operationObject.summary); const extensions = []; const commonExtensions = ["x-codeSamples"]; for (const [key, value] of Object.entries(operationObject)) { if (key.startsWith("x-") && !commonExtensions.includes(key)) { extensions.push({ key, value }); } } const servers = operationObject.servers ?? pathObject.servers ?? openapiData.servers; const security = operationObject.security ?? openapiData.security; // Add security schemes so we know how to handle security. const securitySchemes = openapiData.components?.securitySchemes; // Make sure schemes are lowercase. See: https://github.com/cloud-annotations/docusaurus-plugin-openapi/issues/79 if (securitySchemes) { for (let securityScheme of Object.values(securitySchemes)) { if (securityScheme.type === "http") { securityScheme.scheme = securityScheme.scheme.toLowerCase(); } } } let jsonRequestBodyExample; const content = operationObject.requestBody?.content; let body; for (let key in content) { if ( key.toLowerCase() === "application/json" || key.toLowerCase() === "application/json; charset=utf-8" ) { body = content[key]; break; } } if (body?.schema) { jsonRequestBodyExample = sampleRequestFromSchema(body.schema); } // Handle vendor JSON media types const bodyContent = operationObject.requestBody?.content; if (bodyContent && Object.keys(bodyContent).length > 0) { const firstBodyContentKey = Object.keys(bodyContent)[0]; if (firstBodyContentKey.endsWith("+json")) { const firstBody = bodyContent[firstBodyContentKey]; if (firstBody?.schema) { jsonRequestBodyExample = sampleRequestFromSchema(firstBody.schema); } } } // TODO: Don't include summary temporarilly const { summary, ...defaults } = operationObject; // Merge common parameters with operation parameters // Operation params take precendence over common params if (parameters !== undefined) { if (operationObject.parameters !== undefined) { defaults.parameters = unionBy( operationObject.parameters, parameters, "name" ); } else { defaults.parameters = parameters; } } const opDescription = operationObject.description; let splitDescription: any; if (opDescription) { splitDescription = opDescription.match(/[^\r\n]+/g); } const apiPage: PartialPage<ApiPageMetadata> = { type: "api", id: baseId, infoId: infoId ?? "", unversionedId: baseId, title: title ? title.replace(/((?:^|[^\\])(?:\\{2})*)"/g, "$1'") : "", description: operationObject.description ? operationObject.description.replace( /((?:^|[^\\])(?:\\{2})*)"/g, "$1'" ) : "", frontMatter: { description: splitDescription ? splitDescription[0] .replace(/((?:^|[^\\])(?:\\{2})*)"/g, "$1'") .replace(/\s+$/, "") : "", ...(options?.proxy && { proxy: options.proxy }), ...(options?.hideSendButton && { hide_send_button: options.hideSendButton, }), ...(options?.showExtensions && { show_extensions: options.showExtensions, }), }, api: { ...defaults, ...(extensions.length > 0 && { extensions: extensions }), tags: operationObject.tags, method, path, servers, security, securitySchemes, jsonRequestBodyExample, info: openapiData.info, }, position: operationObject["x-position"] as number | undefined, }; items.push(apiPage); } } // Gather x-webhooks endpoints for (let [path, pathObject] of Object.entries( openapiData["x-webhooks"] ?? openapiData["webhooks"] ?? {} )) { path = "webhook"; const { $ref, description, parameters, servers, summary, ...rest } = pathObject; for (let [method, operationObject] of Object.entries({ ...rest })) { method = "event"; const title = operationObject.summary ?? operationObject.operationId ?? "Missing summary"; if (operationObject.description === undefined) { operationObject.description = operationObject.summary ?? operationObject.operationId ?? ""; } const baseId = operationObject.operationId ? kebabCase(operationObject.operationId) : kebabCase(operationObject.summary); const extensions = []; const commonExtensions = ["x-codeSamples"]; for (const [key, value] of Object.entries(operationObject)) { if (key.startsWith("x-") && !commonExtensions.includes(key)) { extensions.push({ key, value }); } } const servers = operationObject.servers ?? pathObject.servers ?? openapiData.servers; const security = operationObject.security ?? openapiData.security; // Add security schemes so we know how to handle security. const securitySchemes = openapiData.components?.securitySchemes; // Make sure schemes are lowercase. See: https://github.com/cloud-annotations/docusaurus-plugin-openapi/issues/79 if (securitySchemes) { for (let securityScheme of Object.values(securitySchemes)) { if (securityScheme.type === "http") { securityScheme.scheme = securityScheme.scheme.toLowerCase(); } } } let jsonRequestBodyExample; const content = operationObject.requestBody?.content; let body; for (let key in content) { if ( key.toLowerCase() === "application/json" || key.toLowerCase() === "application/json; charset=utf-8" ) { body = content[key]; break; } } if (body?.schema) { jsonRequestBodyExample = sampleRequestFromSchema(body.schema); } // Handle vendor JSON media types const bodyContent = operationObject.requestBody?.content; if (bodyContent) { const firstBodyContentKey = Object.keys(bodyContent)[0]; if (firstBodyContentKey.endsWith("+json")) { const firstBody = bodyContent[firstBodyContentKey]; if (firstBody?.schema) { jsonRequestBodyExample = sampleRequestFromSchema(firstBody.schema); } } } // TODO: Don't include summary temporarilly const { summary, ...defaults } = operationObject; // Merge common parameters with operation parameters // Operation params take precendence over common params if (parameters !== undefined) { if (operationObject.parameters !== undefined) { defaults.parameters = unionBy( operationObject.parameters, parameters, "name" ); } else { defaults.parameters = parameters; } } const opDescription = operationObject.description; let splitDescription: any; if (opDescription) { splitDescription = opDescription.match(/[^\r\n]+/g); } const apiPage: PartialPage<ApiPageMetadata> = { type: "api", id: baseId, infoId: infoId ?? "", unversionedId: baseId, title: title ? title.replace(/((?:^|[^\\])(?:\\{2})*)"/g, "$1'") : "", description: operationObject.description ? operationObject.description.replace( /((?:^|[^\\])(?:\\{2})*)"/g, "$1'" ) : "", frontMatter: { description: splitDescription ? splitDescription[0] .replace(/((?:^|[^\\])(?:\\{2})*)"/g, "$1'") .replace(/\s+$/, "") : "", ...(options?.proxy && { proxy: options.proxy }), ...(options?.hideSendButton && { hide_send_button: options.hideSendButton, }), ...(options?.showExtensions && { show_extensions: options.showExtensions, }), }, api: { ...defaults, ...(extensions.length > 0 && { extensions: extensions }), tags: operationObject.tags, method, path, servers, security, securitySchemes, jsonRequestBodyExample, info: openapiData.info, }, }; items.push(apiPage); } } if ( options?.showSchemas === true || Object.entries(openapiData?.components?.schemas ?? {}) .flatMap(([_, s]) => s["x-tags"]) .filter((item) => !!item).length > 0 ) { // Gather schemas for (let [schema, schemaObject] of Object.entries( openapiData?.components?.schemas ?? {} )) { if (options?.showSchemas === true || schemaObject["x-tags"]) { const baseIdSpaces = schemaObject?.title?.replace(" ", "-").toLowerCase() ?? ""; const baseId = kebabCase(baseIdSpaces); const schemaDescription = schemaObject.description; let splitDescription: any; if (schemaDescription) { splitDescription = schemaDescription.match(/[^\r\n]+/g); } const schemaPage: PartialPage<SchemaPageMetadata> = { type: "schema", id: baseId, infoId: infoId ?? "", unversionedId: baseId, title: schemaObject.title ? schemaObject.title.replace(/((?:^|[^\\])(?:\\{2})*)"/g, "$1'") : schema, description: schemaObject.description ? schemaObject.description.replace( /((?:^|[^\\])(?:\\{2})*)"/g, "$1'" ) : "", frontMatter: { description: splitDescription ? splitDescription[0] .replace(/((?:^|[^\\])(?:\\{2})*)"/g, "$1'") .replace(/\s+$/, "") : "", sample: JSON.stringify(sampleResponseFromSchema(schemaObject)), }, schema: schemaObject, }; items.push(schemaPage); } } } if (sidebarOptions?.categoryLinkSource === "tag") { // Get global tags const tags: TagObject[] = openapiData.tags ?? []; // Get operation tags const apiItems = items.filter((item) => { return item.type === "api"; }) as ApiPageMetadata[]; const operationTags = uniq( apiItems .flatMap((item) => item.api.tags) .filter((item): item is string => !!item) ); // eslint-disable-next-line array-callback-return tags .filter((tag) => operationTags.includes(tag.name!)) // include only tags referenced by operation // eslint-disable-next-line array-callback-return .map((tag) => { const description = getTagDisplayName( tag.name!, openapiData.tags ?? [] ); const tagId = kebabCase(tag.name); const splitDescription = description.match(/[^\r\n]+/g); const tagPage: PartialPage<TagPageMetadata> = { type: "tag", id: tagId, unversionedId: tagId, title: description ?? "", description: description ?? "", frontMatter: { description: splitDescription ? splitDescription[0] .replace(/((?:^|[^\\])(?:\\{2})*)"/g, "$1'") .replace(/\s+$/, "") : "", }, tag: { ...tag, }, }; items.push(tagPage); }); } items.sort((a, b) => { // Items with position come first, sorted by position if (a.position !== undefined && b.position !== undefined) { return a.position - b.position; } // Items with position come before items without position if (a.position !== undefined) { return -1; } if (b.position !== undefined) { return 1; } // If neither has position, maintain original order return 0; }); return items as ApiMetadata[]; } /** * Attach Postman Request objects to the corresponding ApiItems. */ function bindCollectionToApiItems( items: ApiMetadata[], postmanCollection: sdk.Collection ) { postmanCollection.forEachItem((item: any) => { const method = item.request.method.toLowerCase(); const path = item.request.url .getPath({ unresolved: true }) // unresolved returns "/:variableName" instead of "/<type>" .replace(/(?<![a-z0-9-_]+):([a-z0-9-_]+)/gi, "{$1}"); // replace "/:variableName" with "/{variableName}" const apiItem = items.find((item) => { if ( item.type === "info" || item.type === "tag" || item.type === "schema" ) { return false; } return item.api.path === path && item.api.method === method; }); if (apiItem?.type === "api") { apiItem.api.postman = item.request; } }); } interface OpenApiFiles { source: string; sourceDirName: string; data: OpenApiObject; } export async function readOpenapiFiles( openapiPath: string ): Promise<OpenApiFiles[]> { if (!isURL(openapiPath)) { const stat = await fs.lstat(openapiPath); if (stat.isDirectory()) { // TODO: Add config for inlcude/ignore const allFiles = await Globby(["**/*.{json,yaml,yml}"], { cwd: openapiPath, ignore: GlobExcludeDefault, deep: 1, }); const sources = allFiles.filter((x) => !x.includes("_category_")); // todo: regex exclude? return Promise.all( sources.map(async (source) => { // TODO: make a function for this const fullPath = posixPath(path.join(openapiPath, source)); const data = (await loadAndResolveSpec( fullPath )) as unknown as OpenApiObject; return { source: fullPath, // This will be aliased in process. sourceDirName: path.dirname(source), data, }; }) ); } } const data = (await loadAndResolveSpec( openapiPath )) as unknown as OpenApiObject; return [ { source: openapiPath, // This will be aliased in process. sourceDirName: ".", data, }, ]; } export async function processOpenapiFiles( files: OpenApiFiles[], options: APIOptions, sidebarOptions: SidebarOptions ): Promise<[ApiMetadata[], TagObject[][], TagGroupObject[]]> { const promises = files.map(async (file) => { if (file.data !== undefined) { const processedFile = await processOpenapiFile( file.data, options, sidebarOptions ); const itemsObjectsArray = processedFile[0].map((item) => ({ ...item, })); const tags = processedFile[1]; const tagGroups = processedFile[2]; return [itemsObjectsArray, tags, tagGroups]; } console.warn( chalk.yellow( `WARNING: the following OpenAPI spec returned undefined: ${file.source}` ) ); return []; }); const metadata = await Promise.all(promises); const items = metadata .map(function (x) { return x[0]; }) .flat() .filter(function (x) { // Remove undefined items due to transient parsing errors return x !== undefined; }); const tags = metadata .map(function (x) { return x[1]; }) .filter(function (x) { // Remove undefined tags due to transient parsing errors return x !== undefined; }); const tagGroups = metadata .map(function (x) { return x[2]; }) .flat() .filter(function (x) { // Remove undefined tags due to transient parsing errors return x !== undefined; }); return [ items as ApiMetadata[], tags as TagObject[][], tagGroups as TagGroupObject[], ]; } export async function processOpenapiFile( openapiData: OpenApiObject, options: APIOptions, sidebarOptions: SidebarOptions ): Promise<[ApiMetadata[], TagObject[], TagGroupObject[]]> { const postmanCollection = await createPostmanCollection(openapiData); const items = createItems(openapiData, options, sidebarOptions); bindCollectionToApiItems(items, postmanCollection); let tags: TagObject[] = []; if (openapiData.tags !== undefined) { tags = openapiData.tags; } let tagGroups: TagGroupObject[] = []; if (openapiData["x-tagGroups"] !== undefined) { tagGroups = openapiData["x-tagGroups"]; } return [items, tags, tagGroups]; } // order for picking items as a display name of tags const tagDisplayNameProperties = ["x-displayName", "name"] as const; export function getTagDisplayName(tagName: string, tags: TagObject[]): string { // find the very own tagObject const tagObject = tags.find((tagObject) => tagObject.name === tagName) ?? { // if none found, just fake one name: tagName, }; // return the first found and filled value from the property list for (const property of tagDisplayNameProperties) { const displayName = tagObject[property]; if (typeof displayName === "string") { return displayName; } } // always default to the tagName return tagName; }