docusaurus-openapi icon indicating copy to clipboard operation
docusaurus-openapi copied to clipboard

feat(openapi): redesigned page header

Open sean-perkins opened this issue 3 years ago • 2 comments

This PR is the first of many to overhaul the design and experience of the OpenAPI docs page.

What's new

  • Adds ApiItem/Header component to docusaurus-theme-openapi
    • This component is responsible for the new experience of the docs header
    • Color coded request methods
    • Combines the path and currently selected server url to display the fully qualified request url
    • Adds doc-subtitle role to the description.
  • Refactors the render function to add two new line breaks between each markdown generated section (this avoids markdown parser errors with MDX).
  • Refactors the redux provider to include both the ApiItem and the ApiDemoPanel
    • This allows us to share state between the components, such as updating the server url when the user changes between a sandbox and production environment.
  • Includes custom styling in the demo for color coded request method pills.
    • Documents in the demo site how developers can achieve this in their own site. Screen Shot 2022-09-14 at 9 25 47 PM

sean-perkins avatar Sep 03 '22 20:09 sean-perkins

Deploy Preview for docusaurus-openapi ready!

Name Link
Latest commit 4f652d870a7298f1feec8f9e1f231290f4680873
Latest deploy log https://app.netlify.com/sites/docusaurus-openapi/deploys/63142f38a96b050008198154
Deploy Preview https://deploy-preview-211--docusaurus-openapi.netlify.app
Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site settings.

netlify[bot] avatar Sep 03 '22 20:09 netlify[bot]

Ideally I would like to merge this into a next branch instead of main. Technically this is "production ready", but the design will be more cohesive if we wait until the majority of adjustments are made.

sean-perkins avatar Sep 03 '22 20:09 sean-perkins

amazing work here and in the additional PRs started @sean-perkins 🎉 . thank you ❤️

I actually have many similar design requests from the Auth0/Okta API docs design team and we are currently POC'ing this template alongside our MUI-based design system. I can't quite open source this, but I will surely report any feedback in the future.

priley86 avatar Sep 26 '22 19:09 priley86

hey @sean-perkins, just wanted to quickly share a few updates here on our end that I think may help this PR. One gap i've noted in our specs here is the use of oneOf and anyOf in our response schemas. These also exist in Response Body so I have sort combined Schema display into a singular component for now, and used a similar recursion logic. Our display is more "list" style than the proposed modal design though... but eventually we'd love editable fields there inline too. Just food for thought for you!

docusaurus-plugin-openapi/src/markdown/index.ts:

import { ApiPageMetadata, InfoPageMetadata } from "../types";
import { createComponentImports } from "./createComponentImports";
import { createDeprecationNotice } from "./createDeprecationNotice";
import { createDescription } from "./createDescription";
import { createHeader } from "./createHeader";
import { createParamsTable } from "./createParamsTable";
import { createRequestBodyTable } from "./createRequestBodyTable";
import { createResponseMessages } from "./createResponseMessages";
import { createResponseSchema } from "./createResponseSchema";
// import { createResponseSchemaTable } from "./createResponseSchemaTable";
import { createSecurityRequirement } from "./createSecurityRequirement";
import { createVersionBadge } from "./createVersionBadge";
import { render } from "./utils";

export function createApiPageMD({ title, api }: ApiPageMetadata) {
  const {
    deprecated,
    "x-deprecated-description": deprecatedDescription,
    parameters,
    requestBody,
    responses,
    security,
    securitySchemes,
  } = api;
  return render([
    createComponentImports(),
    createHeader(title, api),
    createDeprecationNotice({ deprecated, description: deprecatedDescription }),
    createSecurityRequirement(securitySchemes, security),
    createParamsTable({ parameters, type: "path", title: "Path Parameters" }),
    createParamsTable({ parameters, type: "query", title: "Query Parameters" }),
    createParamsTable({ parameters, type: "header", title: "Headers" }),
    createParamsTable({ parameters, type: "cookie", title: "Cookies" }),
    createRequestBodyTable({ requestBody }),
    // createResponseSchemaTable({ responses }),
    createResponseSchema({ responses }),
    createResponseMessages({ responses }),
  ]);
}

docusaurus-plugin-openapi/src/markdown/createResponseSchema.ts:

/* ============================================================================
 * Copyright (c) Cloud Annotations
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 * ========================================================================== */

import { MediaTypeObject, SchemaObject } from "../openapi/types";
import { ApiItem } from "../types";
import { createDescription } from "./createDescription";
import { Children, create, guard } from "./utils";

function createSchemaListItem(
  properties: SchemaObject["properties"]
): string[] | undefined {
  if (properties === undefined) {
    return undefined;
  }
  return Object.keys(properties).map((prop) => {
    const section = properties![prop];
    return create("SchemaListItem", {
      propertyName: prop,
      required: section.required,
      schema: section,
      children: [
        create("SchemaObject", {
          object: section,
          children: [],
        }),
        createDescription(section.description),
        guard(section.properties || section.items, () => {
          return create("div", {
            children: [
              guard(section.properties, () => {
                return createSchemaListItem(section.properties);
              }),
              guard(section.items, () => {
                return createSchemaListItem(section.items?.properties);
              }),
            ],
          });
        }),
      ],
    });
  });
}

function createResponseSchemaItem(schema: SchemaObject): Children {
  if (schema === undefined) {
    return undefined;
  }
  if (schema.oneOf) {
    // top-level oneOf
    const children = schema.oneOf.map((childSchema) => {
      return create("SchemaListItem", {
        propertyName: "type:",
        schema: childSchema,
        children: createResponseSchemaItem(childSchema),
      });
    });
    return create("SchemaListItem", {
      propertyName: "oneOf:",
      anyOfOneOfSchemas: schema.oneOf,
      schema: schema,
      children: create("", { children: children }),
    });
  }
  if (schema.anyOf) {
    // top-level anyOf
    const children = schema.anyOf.map((childSchema) => {
      return create("SchemaListItem", {
        propertyName: "type:",
        schema: childSchema,
        children: createResponseSchemaItem(childSchema),
      });
    });
    return create("SchemaListItem", {
      propertyName: "anyOf:",
      anyOfOneOfSchemas: schema.anyOf,
      schema: schema,
      children: create("", { children: children }),
    });
  }
  if (schema.items?.properties) {
    // top-level array
    return createSchemaListItem(schema.items?.properties);
  }
  if (schema.properties) {
    // top-level object
    return createSchemaListItem(schema.properties);
  }

  // top-level primitive
  return create("SchemaListItem", {
    propertyName: undefined,
    schema: schema,
    children: [createDescription(schema.description)],
  });
}

interface SchemaProps {
  title: string;
  body: {
    content?: {
      [key: string]: MediaTypeObject;
    };
  };
}

export function createSchemaList({ title, body, ...rest }: SchemaProps) {
  if (body === undefined || body.content === undefined) {
    return undefined;
  }

  // TODO: We just pick the first content-type. Should we diplay multiple and allow user to select?
  const randomFirstKey = Object.keys(body.content)[0];

  const firstBody = body.content[randomFirstKey].schema;

  if (firstBody === undefined) {
    return undefined;
  }

  // we don't show the table if there is no properties to show
  if (firstBody.properties !== undefined) {
    if (Object.keys(firstBody.properties).length === 0) {
      return undefined;
    }
  }
  return firstBody;
}

interface Props {
  responses: ApiItem["responses"];
}

export function createResponseSchema({ responses }: Props) {
  if (responses === undefined) {
    return undefined;
  }

  const codes = Object.keys(responses);
  if (codes.length === 0) {
    return undefined;
  }

  const allSchemas = codes
    .map((code) => {
      const content = responses[code].content;
      const schema = createSchemaList({
        title: code,
        body: { content: content },
      });
      if (schema) {
        const children = createResponseSchemaItem(schema);
        if (children === undefined) {
          return undefined;
        }
        return create("ResponseSchema", {
          responseCode: code,
          description:
            responses[code].description &&
            createDescription(responses[code].description),
          contentType: content && Object.keys(content)[0], // todo: only showing the first contentType for now
          schemaName: undefined, // todo: find schema name / $ref
          type: schema.type, // todo: what should be shown for oneOf/anyOf/allOf?
          children: children,
        });
      }
      return undefined;
    })
    .filter((c) => c !== undefined);

  return allSchemas?.length
    ? create("ContentSection", {
        title: "Response Schemas",
        expand: true,
        children: allSchemas,
      })
    : undefined;
}

docusaurus-plugin-openapi/src/markdown/createRequestBodyTable.ts:

/* ============================================================================
 * Copyright (c) Cloud Annotations
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 * ========================================================================== */

import type { RequestBodyObject, SchemaObject } from "../openapi/types";
import { createDescription } from "./createDescription";
import { create, guard } from "./utils";

interface Props {
  requestBody?: RequestBodyObject;
}

function createBodyParamListItem(
  properties: SchemaObject["properties"]
): string[] | undefined {
  if (properties === undefined) {
    return undefined;
  }
  return Object.keys(properties).map((prop) => {
    const section = properties![prop];
    return create("SchemaListItem", {
      propertyName: prop,
      required: section.required,
      schema: section,
      children: [
        create("SchemaObject", {
          object: section,
          children: [],
        }),
        createDescription(section.description),
        guard(section.properties || section.items, () => {
          return create("div", {
            children: [
              guard(section.properties, () => {
                return createBodyParamListItem(section.properties);
              }),
              guard(section.items, () => {
                return createBodyParamListItem(section.items?.properties);
              }),
            ],
          });
        }),
      ],
    });
  });
}

function createRequestBodyItem(
  schema: SchemaObject | undefined
): string | string[] | undefined {
  if (schema === undefined) {
    return undefined;
  }
  if (schema.oneOf) {
    // top-level oneOf
    const children = schema.oneOf.map((childSchema) => {
      return create("SchemaListItem", {
        propertyName: "type:",
        schema: childSchema,
        children: createRequestBodyItem(childSchema),
      });
    });
    return create("SchemaListItem", {
      propertyName: "oneOf:",
      anyOfOneOfSchemas: schema.oneOf,
      schema: schema,
      children: create("", { children: children }),
    });
  }
  if (schema.anyOf) {
    // top-level anyOf
    const children = schema.anyOf.map((childSchema) => {
      return create("SchemaListItem", {
        propertyName: "type:",
        schema: childSchema,
        children: createRequestBodyItem(childSchema),
      });
    });
    return create("SchemaListItem", {
      propertyName: "anyOf:",
      anyOfOneOfSchemas: schema.anyOf,
      schema: schema,
      children: create("", { children: children }),
    });
  }
  if (schema.items?.properties) {
    // top-level array
    return createBodyParamListItem(schema.items?.properties);
  }
  if (schema.properties) {
    // top-level object
    return createBodyParamListItem(schema.properties);
  }

  // top-level primitive
  return create("SchemaListItem", {
    propertyName: undefined,
    schema: schema,
    children: [createDescription(schema.description)],
  });
}

export function createRequestBodyTable({ requestBody }: Props) {
  if (requestBody === undefined) {
    return undefined;
  }
  return create("ContentSection", {
    title: "Body Parameters",
    expand: true,
    children: Object.keys(requestBody.content).map((contentType) => {
      const { schema } = requestBody.content[contentType];
      const children = createRequestBodyItem(schema);
      if (children === undefined) {
        return undefined;
      }
      return create("RequestBody", {
        contentType: contentType,
        children: children,
      });
    }),
  });
}

I've consolidated SchemaListItem to be used in both templates and make use of something similar to your ListItem though:

docusuarus-theme-openapi/src/theme/ApiItem/SchemaListItem/index.tsx:

/* ============================================================================
 * Copyright (c) Cloud Annotations
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 * ========================================================================== */

import React from "react";

import { getSchemaTypeColor } from "@a0/docusaurus-plugin-openapi/lib/markdown/schemaTypeStyles";
import { SchemaObject as OpenApiSchemaObject } from "@a0/docusaurus-plugin-openapi/src/openapi/types";
import { Label } from "@a0/quantum-product";

import { getSchemaQualifiedType } from "../../../utils";
import ListItem from "../ListItem";
import styles from "./styles.module.css";

interface Props {
  propertyName: string;
  required?: boolean;
  schema: OpenApiSchemaObject;
  paramType?: string;
  anyOfOneOfSchemas?: OpenApiSchemaObject[];
  children?: React.ReactNode;
}

function SchemaListItem({
  propertyName,
  required,
  schema,
  anyOfOneOfSchemas,
  children,
}: Props): JSX.Element | null {
  return (
    <ListItem>
      <div className={styles.propertySection}>
        <div className={styles.paramHeader}>
          {propertyName && <strong>{propertyName}</strong>}
          {anyOfOneOfSchemas && <span>[</span>}
          {anyOfOneOfSchemas &&
            anyOfOneOfSchemas.map((childSchema, i) => {
              return (
                <>
                  <span
                    className={styles.paramType}
                    style={{
                      color: getSchemaTypeColor(
                        getSchemaQualifiedType(childSchema)
                      ),
                    }}
                  >
                    {getSchemaQualifiedType(childSchema)}
                  </span>
                  {i !== anyOfOneOfSchemas.length - 1 && <span>{","}</span>}
                </>
              );
            })}
          {anyOfOneOfSchemas && <span>]</span>}
          <span
            className={styles.paramType}
            style={{
              color: getSchemaTypeColor(getSchemaQualifiedType(schema)),
            }}
          >
            {getSchemaQualifiedType(schema)}
          </span>
          {required && (
            <Label color="danger" variant="outlined">
              Required
            </Label>
          )}
        </div>
        {/* Renders the description, schema object and nested properties */}
        {children}
      </div>
    </ListItem>
  );
}

export default SchemaListItem;

Here's a few screenshots of how this looks...

Screen Shot 2022-10-04 at 7 13 27 PM Screen Shot 2022-10-04 at 7 20 14 PM

priley86 avatar Oct 06 '22 15:10 priley86

fwiw, i have fully swizzled Layout now and injected MUI into a lot of this, so i'd probably need to allow swizzling of these templates if we ever open source this template, but its working quite well and really appreciate all the effort here that made this possible! I will look for any other opportunities to contribute back though (potentially w/ oauth/security additions in the future). 🎈 🍰 🍨

priley86 avatar Oct 06 '22 15:10 priley86