From 00e97a23c26fd56d48661d6030ce7c117aea0ba5 Mon Sep 17 00:00:00 2001 From: Marc L Date: Thu, 7 Mar 2024 18:08:28 -0600 Subject: [PATCH 1/2] feat: Implement schema pages behind a config option `showSchemas` --- .gitignore | 1 + README.md | 12 +- demo/docs/intro.mdx | 12 +- .../docusaurus-plugin-openapi-docs/README.md | 1 + .../src/index.ts | 118 ++++++++++++++++-- .../src/markdown/index.ts | 25 +++- .../src/openapi/openapi.ts | 47 ++++++- .../src/options.ts | 1 + .../src/sidebars/index.ts | 50 ++++++-- .../src/types.ts | 15 ++- .../src/theme/ApiItem/index.tsx | 20 +++ 11 files changed, 268 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index bc9d2e278..5b59d992b 100644 --- a/.gitignore +++ b/.gitignore @@ -137,6 +137,7 @@ dist demo/**/*.api.mdx demo/**/*.info.mdx demo/**/*.tag.mdx +demo/**/*.schema.mdx demo/**/sidebar.js demo/**/versions.json diff --git a/README.md b/README.md index 8e70c5744..df38df936 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ The `docusaurus-plugin-openapi-docs` plugin can be configured with the following | `baseUrl` | `string` | `null` | _Optional:_ Version base URL used when generating version selector dropdown menu. | | `versions` | `object` | `null` | _Optional:_ Set of options for versioning configuration. See below for a list of supported options. | | `markdownGenerators` | `object` | `null` | _Optional:_ Customize MDX content with a set of options for specifying markdown generator functions. See below for a list of supported options. | +| `showSchemas` | `boolean` | `null` | _Optional:_ If set to `true`, generates schema pages and adds them to the sidebar. | `sidebarOptions` can be configured with the following options: @@ -197,11 +198,12 @@ The `docusaurus-plugin-openapi-docs` plugin can be configured with the following `markdownGenerators` can be configured with the following options: -| Name | Type | Default | Description | -| ------------------ | ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `createApiPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for API pages.

**Function type:** `(pageData: ApiPageMetadata) => string` | -| `createInfoPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for info pages.

**Function type:** `(pageData: InfoPageMetadata) => string` | -| `createTagPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for tag pages.

**Function type:** `(pageData: TagPageMetadata) => string` | +| Name | Type | Default | Description | +| -------------------- | ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `createApiPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for API pages.

**Function type:** `(pageData: ApiPageMetadata) => string` | +| `createInfoPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for info pages.

**Function type:** `(pageData: InfoPageMetadata) => string` | +| `createTagPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for tag pages.

**Function type:** `(pageData: TagPageMetadata) => string` | +| `createSchemaPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for schema pages.

**Function type:** `(pageData: SchemaPageMetadata) => string` | ## CLI Usage diff --git a/demo/docs/intro.mdx b/demo/docs/intro.mdx index e5d43e55d..c0c911596 100644 --- a/demo/docs/intro.mdx +++ b/demo/docs/intro.mdx @@ -336,6 +336,7 @@ The `docusaurus-plugin-openapi-docs` plugin can be configured with the following | `baseUrl` | `string` | `null` | _Optional:_ Version base URL used when generating version selector dropdown menu. | | `versions` | `object` | `null` | _Optional:_ Set of options for versioning configuration. See below for a list of supported options. | | `markdownGenerators` | `object` | `null` | _Optional:_ Customize MDX content with a set of options for specifying markdown generator functions. See below for a list of supported options. | +| `showSchemas` | `boolean` | `null` | _Optional:_ If set to `true`, generates schema pages and adds them to the sidebar. | ### sidebarOptions @@ -373,11 +374,12 @@ All versions will automatically inherit `sidebarOptions` from the parent/base co `markdownGenerators` can be configured with the following options: -| Name | Type | Default | Description | -| ------------------ | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------| -| `createApiPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for API pages.

**Function type:** `(pageData: ApiPageMetadata) => string` | -| `createInfoPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for info pages.

**Function type:** `(pageData: InfoPageMetadata) => string` | -| `createTagPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for tag pages.

**Function type:** `(pageData: TagPageMetadata) => string` | +| Name | Type | Default | Description | +| ------------------- | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------| +| `createApiPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for API pages.

**Function type:** `(pageData: ApiPageMetadata) => string` | +| `createInfoPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for info pages.

**Function type:** `(pageData: InfoPageMetadata) => string` | +| `createTagPageMD` | `function` | `null` | _Optional:_ Returns a string of the raw markdown body for tag pages.

**Function type:** `(pageData: TagPageMetadata) => string` | +| `createSchemaPageMD`| `function` | `null` | _Optional:_ Returns a string of the raw markdown body for schema pages.

**Function type:** `(pageData: SchemaPageMetadata) => string` | :::info NOTE If a config option is not provided, its default markdown generator will be used. diff --git a/packages/docusaurus-plugin-openapi-docs/README.md b/packages/docusaurus-plugin-openapi-docs/README.md index 5be5e9751..1895329ba 100644 --- a/packages/docusaurus-plugin-openapi-docs/README.md +++ b/packages/docusaurus-plugin-openapi-docs/README.md @@ -159,6 +159,7 @@ The `docusaurus-plugin-openapi-docs` plugin can be configured with the following | `baseUrl` | `string` | `null` | _Optional:_ Version base URL used when generating version selector dropdown menu. | | `versions` | `object` | `null` | _Optional:_ Set of options for versioning configuration. See below for a list of supported options. | | `markdownGenerators` | `object` | `null` | _Optional:_ Customize MDX content with a set of options for specifying markdown generator functions. See below for a list of supported options. | +| `showSchemas` | `boolean` | `null` | _Optional:_ If set to `true`, generates schema pages and adds them to the sidebar. | `sidebarOptions` can be configured with the following options: diff --git a/packages/docusaurus-plugin-openapi-docs/src/index.ts b/packages/docusaurus-plugin-openapi-docs/src/index.ts index ea679f25b..0755c2065 100644 --- a/packages/docusaurus-plugin-openapi-docs/src/index.ts +++ b/packages/docusaurus-plugin-openapi-docs/src/index.ts @@ -14,11 +14,25 @@ import { Globby, posixPath } from "@docusaurus/utils"; import chalk from "chalk"; import { render } from "mustache"; -import { createApiPageMD, createInfoPageMD, createTagPageMD } from "./markdown"; +import { + createApiPageMD, + createInfoPageMD, + createSchemaPageMD, + createTagPageMD, +} from "./markdown"; import { readOpenapiFiles, processOpenapiFiles } from "./openapi"; import { OptionsSchema } from "./options"; import generateSidebarSlice from "./sidebars"; -import type { PluginOptions, LoadedContent, APIOptions } from "./types"; +import type { + PluginOptions, + LoadedContent, + APIOptions, + ApiMetadata, + ApiPageMetadata, + InfoPageMetadata, + TagPageMetadata, + SchemaPageMetadata, +} from "./types"; export function isURL(str: string): boolean { return /^(https?:)\/\//m.test(str); @@ -244,12 +258,43 @@ import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; \`\`\` `; + const schemaMdTemplate = `--- +id: {{{id}}} +title: "{{{title}}}" +description: "{{{frontMatter.description}}}" +sidebar_label: "{{{title}}}" +hide_title: true +schema: true +custom_edit_url: null +--- + +{{{markdown}}} + `; + const apiPageGenerator = markdownGenerators?.createApiPageMD ?? createApiPageMD; const infoPageGenerator = markdownGenerators?.createInfoPageMD ?? createInfoPageMD; const tagPageGenerator = markdownGenerators?.createTagPageMD ?? createTagPageMD; + const schemaPageGenerator = + markdownGenerators?.createSchemaPageMD ?? createSchemaPageMD; + + const pageGeneratorByType: { + [key in ApiMetadata["type"]]: ( + pageData: { + api: ApiPageMetadata; + info: InfoPageMetadata; + tag: TagPageMetadata; + schema: SchemaPageMetadata; + }[key] + ) => string; + } = { + api: apiPageGenerator, + info: infoPageGenerator, + tag: tagPageGenerator, + schema: schemaPageGenerator, + }; loadedApi.map(async (item) => { if (item.type === "info") { @@ -257,12 +302,7 @@ import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; item.downloadUrl = downloadUrl; } } - const markdown = - item.type === "api" - ? apiPageGenerator(item) - : item.type === "info" - ? infoPageGenerator(item) - : tagPageGenerator(item); + const markdown = pageGeneratorByType[item.type](item as any); item.markdown = markdown; if (item.type === "api") { // opportunity to compress JSON @@ -363,6 +403,49 @@ import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; } } } + + if (item.type === "schema") { + if (!fs.existsSync(`${outputDir}/schemas/${item.id}.schema.mdx`)) { + if (!fs.existsSync(`${outputDir}/schemas`)) { + try { + fs.mkdirSync(`${outputDir}/schemas`, { recursive: true }); + console.log( + chalk.green(`Successfully created "${outputDir}/schemas"`) + ); + } catch (err) { + console.error( + chalk.red(`Failed to create "${outputDir}/schemas"`), + chalk.yellow(err) + ); + } + } + try { + // kebabCase(arg) returns 0-length string when arg is undefined + if (item.id.length === 0) { + throw Error("Schema must have title defined"); + } + // eslint-disable-next-line testing-library/render-result-naming-convention + const schemaView = render(schemaMdTemplate, item); + fs.writeFileSync( + `${outputDir}/schemas/${item.id}.schema.mdx`, + schemaView, + "utf8" + ); + console.log( + chalk.green( + `Successfully created "${outputDir}/${item.id}.schema.mdx"` + ) + ); + } catch (err) { + console.error( + chalk.red( + `Failed to write "${outputDir}/${item.id}.schema.mdx"` + ), + chalk.yellow(err) + ); + } + } + } return; }); @@ -380,6 +463,10 @@ import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; cwd: path.resolve(apiDir), deep: 1, }); + const schemaMdxFiles = await Globby(["*.schema.mdx"], { + cwd: path.resolve(apiDir, "schemas"), + deep: 1, + }); const sidebarFile = await Globby(["sidebar.js"], { cwd: path.resolve(apiDir), deep: 1, @@ -397,6 +484,21 @@ import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; }) ); + schemaMdxFiles.map((mdx) => + fs.unlink(`${apiDir}/schemas/${mdx}`, (err) => { + if (err) { + console.error( + chalk.red(`Cleanup failed for "${apiDir}/schemas/${mdx}"`), + chalk.yellow(err) + ); + } else { + console.log( + chalk.green(`Cleanup succeeded for "${apiDir}/schemas/${mdx}"`) + ); + } + }) + ); + sidebarFile.map((sidebar) => fs.unlink(`${apiDir}/${sidebar}`, (err) => { if (err) { diff --git a/packages/docusaurus-plugin-openapi-docs/src/markdown/index.ts b/packages/docusaurus-plugin-openapi-docs/src/markdown/index.ts index 4527dab38..8a8d316d9 100644 --- a/packages/docusaurus-plugin-openapi-docs/src/markdown/index.ts +++ b/packages/docusaurus-plugin-openapi-docs/src/markdown/index.ts @@ -11,7 +11,12 @@ import { MediaTypeObject, SecuritySchemeObject, } from "../openapi/types"; -import { ApiPageMetadata, InfoPageMetadata, TagPageMetadata } from "../types"; +import { + ApiPageMetadata, + InfoPageMetadata, + SchemaPageMetadata, + TagPageMetadata, +} from "../types"; import { createAuthentication } from "./createAuthentication"; import { createAuthorization } from "./createAuthorization"; import { createCallbacks } from "./createCallbacks"; @@ -26,11 +31,12 @@ import { createMethodEndpoint } from "./createMethodEndpoint"; import { createParamsDetails } from "./createParamsDetails"; import { createRequestBodyDetails } from "./createRequestBodyDetails"; import { createRequestHeader } from "./createRequestHeader"; +import { createNodes } from "./createSchema"; import { createStatusCodes } from "./createStatusCodes"; import { createTermsOfService } from "./createTermsOfService"; import { createVendorExtensions } from "./createVendorExtensions"; import { createVersionBadge } from "./createVersionBadge"; -import { greaterThan, lessThan, render } from "./utils"; +import { create, greaterThan, lessThan, render } from "./utils"; interface RequestBodyProps { title: string; @@ -130,3 +136,18 @@ export function createInfoPageMD({ export function createTagPageMD({ tag: { description } }: TagPageMetadata) { return render([createDescription(description)]); } + +export function createSchemaPageMD({ schema }: SchemaPageMetadata) { + const { title = "", description } = schema; + return render([ + `import DiscriminatorTabs from "@theme/DiscriminatorTabs";\n`, + `import SchemaItem from "@theme/SchemaItem";\n`, + `import SchemaTabs from "@theme/SchemaTabs";\n`, + `import TabItem from "@theme/TabItem";\n\n`, + createHeading(title.replace(lessThan, "<").replace(greaterThan, ">")), + createDescription(description), + create("ul", { + children: createNodes(schema, "response"), + }), + ]); +} diff --git a/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts b/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts index 8dde3c8d0..a9d45bd2a 100644 --- a/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts +++ b/packages/docusaurus-plugin-openapi-docs/src/openapi/openapi.ts @@ -24,6 +24,7 @@ import { APIOptions, ApiPageMetadata, InfoPageMetadata, + SchemaPageMetadata, SidebarOptions, TagPageMetadata, } from "../types"; @@ -409,6 +410,46 @@ function createItems( } } + if (options?.showSchemas === true) { + // Gather schemas + for (let [schema, schemaObject] of Object.entries( + openapiData?.components?.schemas ?? {} + )) { + 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 = { + 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+$/, "") + : "", + }, + schema: schemaObject, + }; + + items.push(schemaPage); + } + } + if (sidebarOptions?.categoryLinkSource === "tag") { // Get global tags const tags: TagObject[] = openapiData.tags ?? []; @@ -471,7 +512,11 @@ function bindCollectionToApiItems( .getPath({ unresolved: true }) // unresolved returns "/:variableName" instead of "/" .replace(/(? { - if (item.type === "info" || item.type === "tag") { + if ( + item.type === "info" || + item.type === "tag" || + item.type === "schema" + ) { return false; } return item.api.path === path && item.api.method === method; diff --git a/packages/docusaurus-plugin-openapi-docs/src/options.ts b/packages/docusaurus-plugin-openapi-docs/src/options.ts index d2a56805d..35d98a229 100644 --- a/packages/docusaurus-plugin-openapi-docs/src/options.ts +++ b/packages/docusaurus-plugin-openapi-docs/src/options.ts @@ -37,6 +37,7 @@ export const OptionsSchema = Joi.object({ showExtensions: Joi.boolean(), sidebarOptions: sidebarOptions, markdownGenerators: markdownGenerators, + showSchemas: Joi.boolean(), version: Joi.string().when("versions", { is: Joi.exist(), then: Joi.required(), diff --git a/packages/docusaurus-plugin-openapi-docs/src/sidebars/index.ts b/packages/docusaurus-plugin-openapi-docs/src/sidebars/index.ts index 285415ead..8ada7af18 100644 --- a/packages/docusaurus-plugin-openapi-docs/src/sidebars/index.ts +++ b/packages/docusaurus-plugin-openapi-docs/src/sidebars/index.ts @@ -24,6 +24,7 @@ import type { APIOptions, ApiPageMetadata, ApiMetadata, + SchemaPageMetadata, } from "../types"; function isApiItem(item: ApiMetadata): item is ApiMetadata { @@ -34,6 +35,10 @@ function isInfoItem(item: ApiMetadata): item is ApiMetadata { return item.type === "info"; } +function isSchemaItem(item: ApiMetadata): item is ApiMetadata { + return item.type === "schema"; +} + function groupByTags( items: ApiPageMetadata[], sidebarOptions: SidebarOptions, @@ -55,6 +60,7 @@ function groupByTags( const apiItems = items.filter(isApiItem); const infoItems = items.filter(isInfoItem); + const schemaItems = items.filter(isSchemaItem); const intros = infoItems.map((item: any) => { return { id: item.id, @@ -85,23 +91,30 @@ function groupByTags( const basePath = docPath ? outputDir.split(docPath!)[1].replace(/^\/+/g, "") : outputDir.slice(outputDir.indexOf("/", 1)).replace(/^\/+/g, ""); - function createDocItem(item: ApiPageMetadata): SidebarItemDoc { + function createDocItem( + item: ApiPageMetadata | SchemaPageMetadata + ): SidebarItemDoc { const sidebar_label = item.frontMatter.sidebar_label; const title = item.title; - const id = item.id; + const id = item.type === "schema" ? `schemas/${item.id}` : item.id; + const className = + item.type === "api" + ? clsx( + { + "menu__list-item--deprecated": item.api.deprecated, + "api-method": !!item.api.method, + }, + item.api.method + ) + : clsx({ + "menu__list-item--deprecated": item.schema.deprecated, + }); return { type: "doc" as const, - id: - basePath === "" || undefined ? `${item.id}` : `${basePath}/${item.id}`, + id: basePath === "" || undefined ? `${id}` : `${basePath}/${id}`, label: (sidebar_label as string) ?? title ?? id, customProps: customProps, - className: clsx( - { - "menu__list-item--deprecated": item.api.deprecated, - "api-method": !!item.api.method, - }, - item.api.method - ), + className: className ? className : undefined, }; } @@ -201,13 +214,26 @@ function groupByTags( ]; } + let schemas: SidebarItemCategory[] = []; + if (schemaItems.length > 0) { + schemas = [ + { + type: "category" as const, + label: "Schemas", + collapsible: sidebarCollapsible!, + collapsed: sidebarCollapsed!, + items: schemaItems.map(createDocItem), + }, + ]; + } + // Shift root intro doc to top of sidebar // TODO: Add input validation for categoryLinkSource options if (rootIntroDoc && categoryLinkSource !== "info") { tagged.unshift(rootIntroDoc as any); } - return [...tagged, ...untagged]; + return [...tagged, ...untagged, ...schemas]; } export default function generateSidebarSlice( diff --git a/packages/docusaurus-plugin-openapi-docs/src/types.ts b/packages/docusaurus-plugin-openapi-docs/src/types.ts index 7c12d0805..704c93c27 100644 --- a/packages/docusaurus-plugin-openapi-docs/src/types.ts +++ b/packages/docusaurus-plugin-openapi-docs/src/types.ts @@ -10,6 +10,7 @@ import type Request from "@paloaltonetworks/postman-collection"; import { InfoObject, OperationObject, + SchemaObject, SecuritySchemeObject, TagObject, } from "./openapi/types"; @@ -44,12 +45,14 @@ export interface APIOptions { }; proxy?: string; markdownGenerators?: MarkdownGenerator; + showSchemas?: boolean; } export interface MarkdownGenerator { createApiPageMD?: (pageData: ApiPageMetadata) => string; createInfoPageMD?: (pageData: InfoPageMetadata) => string; createTagPageMD?: (pageData: TagPageMetadata) => string; + createSchemaPageMD?: (pageData: SchemaPageMetadata) => string; } export interface SidebarOptions { @@ -72,7 +75,11 @@ export interface LoadedContent { // loadedDocs: DocPageMetadata[]; TODO: cleanup } -export type ApiMetadata = ApiPageMetadata | InfoPageMetadata | TagPageMetadata; +export type ApiMetadata = + | ApiPageMetadata + | InfoPageMetadata + | TagPageMetadata + | SchemaPageMetadata; export interface ApiMetadataBase { sidebar?: string; @@ -131,6 +138,12 @@ export interface TagPageMetadata extends ApiMetadataBase { markdown?: string; } +export interface SchemaPageMetadata extends ApiMetadataBase { + type: "schema"; + schema: SchemaObject; + markdown?: string; +} + export type ApiInfo = InfoObject; export interface ApiNavLink { diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiItem/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiItem/index.tsx index ab193c1d0..9b8f95ea6 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiItem/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiItem/index.tsx @@ -44,12 +44,17 @@ interface ApiFrontMatter extends DocFrontMatter { readonly api?: ApiItemType; } +interface SchemaFrontMatter extends DocFrontMatter { + readonly schema?: boolean; +} + export default function ApiItem(props: Props): JSX.Element { const docHtmlClassName = `docs-doc-id-${props.content.metadata.unversionedId}`; const MDXComponent = props.content; const { frontMatter } = MDXComponent; const { info_path: infoPath } = frontMatter as DocFrontMatter; let { api } = frontMatter as ApiFrontMatter; + const { schema } = frontMatter as SchemaFrontMatter; // decompress and parse if (api) { api = JSON.parse( @@ -159,6 +164,21 @@ export default function ApiItem(props: Props): JSX.Element { ); + } else if (schema) { + return ( + + + + +
+
+ +
+
+
+
+
+ ); } // Non-API docs From cb4e7d21253aeaeed1719c25fe52319d3d395683 Mon Sep 17 00:00:00 2001 From: Marc L Date: Thu, 7 Mar 2024 18:08:55 -0600 Subject: [PATCH 2/2] Add `showSchemas` to petstore demo --- demo/docusaurus.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/demo/docusaurus.config.js b/demo/docusaurus.config.js index 409581e4e..ba5cfa7c4 100644 --- a/demo/docusaurus.config.js +++ b/demo/docusaurus.config.js @@ -239,6 +239,7 @@ const config = { downloadUrl: "https://raw.githubusercontent.com/PaloAltoNetworks/docusaurus-openapi-docs/main/demo/examples/petstore.yaml", hideSendButton: false, + showSchemas: true, }, cos: { specPath: "examples/openapi-cos.json",