From 7ea293bb0547bc712054e42b7edc4a62c0387b2e Mon Sep 17 00:00:00 2001 From: Zach Shilton <4624598+zchsh@users.noreply.github.com> Date: Thu, 31 Oct 2024 17:50:44 -0400 Subject: [PATCH] OpenAPI V2 - implement sidebar (#2594) * fix IS_VERCEL_DEPLOY check logic Previously if VERCEL_ENV was equal to anything other than `development`, then we'd have `IS_VERCEL_DEPLOY` as `true`. This isn't accurate in cases where `VERCEL_ENV` is undefined, which is sometimes the case when running the site locally. * add note to preview landing on reload issue * add open api sidebar related utils * add schema transforms * add disentangled sidebar link components * use new sidebar components, update types * remove now-unused utility * enable new features in preview tool * fix bad property access in test * avoid conditionals within smaller components * remove placeholder * rm dupe fn * Fix typo in comment on sidebar-link Co-authored-by: Noel Quiles <3746694+EnMod@users.noreply.github.com> * Fix missing wording in preview fallback page --------- Co-authored-by: Noel Quiles <3746694+EnMod@users.noreply.github.com> --- src/pages/api/open-api-docs-preview-v2.ts | 3 +- src/views/open-api-docs-preview-v2/index.tsx | 5 +- src/views/open-api-docs-preview-v2/server.ts | 2 +- .../utils/get-props-from-preview-data.ts | 48 +++++++- .../sidebar-link-external/index.tsx | 49 ++++++++ .../sidebar-link-external/style.module.css | 16 +++ .../sidebar-link-with-product-icon/index.tsx | 55 +++++++++ .../style.module.css | 88 ++++++++++++++ .../components/sidebar-link/index.tsx | 69 +++++++++++ .../components/sidebar-link/style.module.css | 27 +++++ .../sidebar-resource-links/index.tsx | 49 ++++++++ .../sidebar-resource-links/style.module.css | 12 ++ .../components/sidebar/index.tsx | 81 +++++++++++++ .../components/sidebar/style.module.css | 12 ++ src/views/open-api-docs-view-v2/index.tsx | 54 ++++----- .../schema-transform-shorten-hcp.ts | 19 +++ .../schema-transform-title.ts | 20 ++++ src/views/open-api-docs-view-v2/server.ts | 95 ++++++++++++--- src/views/open-api-docs-view-v2/types.ts | 108 +++++++++++++++--- .../utils/get-nav-items.ts | 62 ---------- .../get-operation-group-key-from-path.ts | 18 +++ .../utils/get-operation-objects.ts | 45 ++++++++ .../utils/group-items-by-key.test.ts | 40 +++++++ .../utils/group-items-by-key.ts | 34 ++++++ .../utils/shorten-hcp.ts | 11 ++ .../utils/truncate-hcp-operation-path.ts | 42 +++++++ 26 files changed, 931 insertions(+), 133 deletions(-) create mode 100644 src/views/open-api-docs-view-v2/components/sidebar-link-external/index.tsx create mode 100644 src/views/open-api-docs-view-v2/components/sidebar-link-external/style.module.css create mode 100644 src/views/open-api-docs-view-v2/components/sidebar-link-with-product-icon/index.tsx create mode 100644 src/views/open-api-docs-view-v2/components/sidebar-link-with-product-icon/style.module.css create mode 100644 src/views/open-api-docs-view-v2/components/sidebar-link/index.tsx create mode 100644 src/views/open-api-docs-view-v2/components/sidebar-link/style.module.css create mode 100644 src/views/open-api-docs-view-v2/components/sidebar-resource-links/index.tsx create mode 100644 src/views/open-api-docs-view-v2/components/sidebar-resource-links/style.module.css create mode 100644 src/views/open-api-docs-view-v2/components/sidebar/index.tsx create mode 100644 src/views/open-api-docs-view-v2/components/sidebar/style.module.css create mode 100644 src/views/open-api-docs-view-v2/schema-transforms/schema-transform-shorten-hcp.ts create mode 100644 src/views/open-api-docs-view-v2/schema-transforms/schema-transform-title.ts delete mode 100644 src/views/open-api-docs-view-v2/utils/get-nav-items.ts create mode 100644 src/views/open-api-docs-view-v2/utils/get-operation-group-key-from-path.ts create mode 100644 src/views/open-api-docs-view-v2/utils/get-operation-objects.ts create mode 100644 src/views/open-api-docs-view-v2/utils/group-items-by-key.test.ts create mode 100644 src/views/open-api-docs-view-v2/utils/group-items-by-key.ts create mode 100644 src/views/open-api-docs-view-v2/utils/shorten-hcp.ts create mode 100644 src/views/open-api-docs-view-v2/utils/truncate-hcp-operation-path.ts diff --git a/src/pages/api/open-api-docs-preview-v2.ts b/src/pages/api/open-api-docs-preview-v2.ts index 8186bb445a..2d8ee0718d 100644 --- a/src/pages/api/open-api-docs-preview-v2.ts +++ b/src/pages/api/open-api-docs-preview-v2.ts @@ -8,13 +8,12 @@ import path from 'path' import { randomUUID } from 'crypto' // Types import type { NextApiRequest, NextApiResponse } from 'next' -import { OpenApiPreviewV2InputValues } from 'views/open-api-docs-preview-v2/components/open-api-preview-inputs' /** * Setup for temporary file storage */ // Determine if we're deploying to Vercel, this affects the temporary directory -const IS_VERCEL_DEPLOY = process.env.VERCEL_ENV !== 'development' +const IS_VERCEL_DEPLOY = typeof process.env.VERCEL_ENV === 'string' // Determine the temporary directory to use, based on Vercel deploy or not const TMP_DIR = IS_VERCEL_DEPLOY ? '/tmp' : path.join(process.cwd(), '.tmp') // Ensure the temporary directory exists, so we can stash files diff --git a/src/views/open-api-docs-preview-v2/index.tsx b/src/views/open-api-docs-preview-v2/index.tsx index 0b90711ca7..7008e226db 100644 --- a/src/views/open-api-docs-preview-v2/index.tsx +++ b/src/views/open-api-docs-preview-v2/index.tsx @@ -54,7 +54,10 @@ function OpenApiDocsPreviewViewV2({

OpenAPI Preview Tool

- {`Please use the input form on this page to upload your spec. After submitting the form, the page should reload and display a preview.`} + {`Please use the input form on this page to upload your spec. After submitting the form, the page should reload and display a preview. For security reasons, after around an hour, your submitted file will be deleted the next time this page is loaded. You will need to re-submit your spec if you're working with this tool for longer than an hour.`} +

+

+ {`This page may appear unexpectedly after navigation rather the expected preview content due to differences in how this preview tool works relative to production contexts. Reloading the page may resolve the issue. If reloading the page doesn't do anything, you may need to re-submit your spec file. If you're experiencing frequent issues requiring page reloads, reach out in #team-web-support in Slack.`}

diff --git a/src/views/open-api-docs-preview-v2/server.ts b/src/views/open-api-docs-preview-v2/server.ts index 687bb1adad..28b3d02ee1 100644 --- a/src/views/open-api-docs-preview-v2/server.ts +++ b/src/views/open-api-docs-preview-v2/server.ts @@ -27,7 +27,7 @@ const IS_PRODUCTION = process.env.VERCEL_ENV === 'production' * where NextJS decides to start on a different port (eg if 3000 is in use), * but I couldn't find a way to detect that, and for now, this seems sufficient. */ -const IS_VERCEL_DEPLOY = process.env.VERCEL_ENV !== 'development' +const IS_VERCEL_DEPLOY = typeof process.env.VERCEL_ENV === 'string' const BASE_URL = IS_VERCEL_DEPLOY ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000' diff --git a/src/views/open-api-docs-preview-v2/utils/get-props-from-preview-data.ts b/src/views/open-api-docs-preview-v2/utils/get-props-from-preview-data.ts index 40f7c6c8a3..2701fb4077 100644 --- a/src/views/open-api-docs-preview-v2/utils/get-props-from-preview-data.ts +++ b/src/views/open-api-docs-preview-v2/utils/get-props-from-preview-data.ts @@ -4,8 +4,14 @@ */ import { getStaticProps } from 'views/open-api-docs-view-v2/server' +// Utils +import { getOperationGroupKeyFromPath } from 'views/open-api-docs-view-v2/utils/get-operation-group-key-from-path' +import { schemaTransformShortenHcp } from 'views/open-api-docs-view-v2/schema-transforms/schema-transform-shorten-hcp' // Types -import type { OpenApiDocsViewV2Props } from 'views/open-api-docs-view-v2/types' +import type { + OpenApiDocsViewV2Props, + OpenApiDocsViewV2Config, +} from 'views/open-api-docs-view-v2/types' import type { OpenApiPreviewV2InputValues } from '../components/open-api-preview-inputs' /** @@ -28,10 +34,44 @@ export default async function getPropsFromPreviewData( if (!previewData) { return null } - // Use the incoming preview data to generate static props for the view - return await getStaticProps({ + // Set up transformers for the spec. Typically we want to avoid these, + // and prefer to have content updates made at the content source... but + // some shims are used often enough that they feel worth including in the + // preview too. Namely, shortening to `HCP` in the spec title. + const schemaTransforms = [schemaTransformShortenHcp] + // Build page configuration based on the input values + const pageConfig: OpenApiDocsViewV2Config = { basePath: '/open-api-docs-preview-v2', operationSlug, openApiJsonString: previewData.openApiJsonString, - }) + schemaTransforms, + // A generic set of resource links, as a preview of what typically + // gets added to an OpenAPI docs page. + resourceLinks: [ + { + text: 'Tutorial Library', + href: '/tutorials/library', + }, + { + text: 'Certifications', + href: '/certifications', + }, + { + text: 'Community', + href: 'https://discuss.hashicorp.com/', + }, + { + text: 'Support', + href: 'https://www.hashicorp.com/customer-success', + }, + ], + } + // If the user has requested to group operations by path, we'll do so + // by providing a custom `getOperationGroupKey` function. If this is omitted, + // we go with the default behaviour of grouping operations based on `tag`. + if (previewData.groupOperationsByPath) { + pageConfig.getOperationGroupKey = getOperationGroupKeyFromPath + } + // Use the page config to generate static props for the view + return await getStaticProps(pageConfig) } diff --git a/src/views/open-api-docs-view-v2/components/sidebar-link-external/index.tsx b/src/views/open-api-docs-view-v2/components/sidebar-link-external/index.tsx new file mode 100644 index 0000000000..c0f8df4396 --- /dev/null +++ b/src/views/open-api-docs-view-v2/components/sidebar-link-external/index.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// Components +import { IconExternalLink16 } from '@hashicorp/flight-icons/svg-react/external-link-16' +import { SidebarLink, SidebarLinkText } from '../sidebar-link' +// Types +import type { PropsWithChildren } from 'react' +// Styles +import s from './style.module.css' + +/** + * Render a SidebarLink with an external link icon. + */ +export function SidebarLinkExternal({ + href, + children, +}: PropsWithChildren<{ + href: string +}>) { + return ( + + {children} + + + + + ) +} diff --git a/src/views/open-api-docs-view-v2/components/sidebar-link-external/style.module.css b/src/views/open-api-docs-view-v2/components/sidebar-link-external/style.module.css new file mode 100644 index 0000000000..b1fbbbbf3a --- /dev/null +++ b/src/views/open-api-docs-view-v2/components/sidebar-link-external/style.module.css @@ -0,0 +1,16 @@ +.root { + display: flex; + gap: 8px; + justify-content: space-between; + align-items: center; +} + +.icon { + /* flex-shrink: 0 prevents the icon from shrinking */ + flex-shrink: 0; + + /* display: block removes built-in margin that'd otherwise appear */ + & > svg { + display: block; + } +} diff --git a/src/views/open-api-docs-view-v2/components/sidebar-link-with-product-icon/index.tsx b/src/views/open-api-docs-view-v2/components/sidebar-link-with-product-icon/index.tsx new file mode 100644 index 0000000000..d2e336080b --- /dev/null +++ b/src/views/open-api-docs-view-v2/components/sidebar-link-with-product-icon/index.tsx @@ -0,0 +1,55 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// Components +import ProductIcon from 'components/product-icon' +import Text from 'components/text' +import { SidebarLink } from '../sidebar-link' +// Utils +import { isProductSlug } from 'lib/products' +// Types +import type { ProductSlug } from 'types/products' +// Styles +import s from './style.module.css' + +/** + * Render a fancy-looking, product themed linked sidebar item. + * + * Intended to be used as a kind of title-ish element, to establish hierarchy + * within sidebar contents. + * + * This component is largely the same as SidebarNavHighlightItem, but + * requires `href`, and eliminates some related conditional rendering. + * See notes in `../sidebar-link` for more thoughts on a potential move + * away from previously implemented sidebar link components. + */ +export function SidebarLinkWithProductIcon({ + productSlug, + text, + href, + isActive, +}: { + productSlug: ProductSlug + text: string + href: string + isActive: boolean +}) { + const icon = isProductSlug(productSlug) ? ( + + ) : null + + return ( + + {icon} + + {text} + + + ) +} diff --git a/src/views/open-api-docs-view-v2/components/sidebar-link-with-product-icon/style.module.css b/src/views/open-api-docs-view-v2/components/sidebar-link-with-product-icon/style.module.css new file mode 100644 index 0000000000..26cfd8916c --- /dev/null +++ b/src/views/open-api-docs-view-v2/components/sidebar-link-with-product-icon/style.module.css @@ -0,0 +1,88 @@ +.root { + /* Generic default theme, eg. for HCP and Sentinel */ + --gradient-start: var(--token-color-palette-neutral-100); + --gradient-stop: var(--token-color-palette-neutral-50); + --inset-border-color: var(--token-color-palette-neutral-200); + + /* Set up themed property helpers */ + --inset-border-shadow: inset 0 0 0 1px var(--inset-border-color); + --gradient-background: linear-gradient( + 315deg, + var(--gradient-start) 0%, + var(--gradient-stop) 100% + ); + + /* Layout icon and text */ + display: flex; + align-items: center; + gap: 8px; + + /* Builds on SidebarLink hover style, adds gradient background */ + &:hover { + background: var(--gradient-background); + } + + /* Builds on SidebarLink current page style, adds gradient background, + and box-shadow border when not focused. */ + &[aria-current='page'] { + background: var(--gradient-background); + + &:not(:focus-visible) { + box-shadow: var(--inset-border-shadow); + } + } +} + +.icon { + flex-shrink: 0; +} + +/* Theming by product slug */ + +.theme-terraform { + --gradient-start: var(--token-color-terraform-gradient-faint-stop); + --gradient-stop: var(--token-color-terraform-gradient-faint-start); + --inset-border-color: var(--token-color-terraform-border); +} + +.theme-packer { + --gradient-start: var(--token-color-packer-gradient-faint-stop); + --gradient-stop: var(--token-color-packer-gradient-faint-start); + --inset-border-color: var(--token-color-packer-border); +} + +.theme-consul { + --gradient-start: var(--token-color-consul-gradient-faint-stop); + --gradient-stop: var(--token-color-consul-gradient-faint-start); + --inset-border-color: var(--token-color-consul-border); +} + +.theme-vault { + --gradient-start: var(--token-color-vault-gradient-faint-stop); + --gradient-stop: var(--token-color-vault-gradient-faint-start); + --inset-border-color: var(--token-color-vault-border); +} + +.theme-boundary { + --gradient-start: var(--token-color-boundary-gradient-faint-stop); + --gradient-stop: var(--token-color-boundary-gradient-faint-start); + --inset-border-color: var(--token-color-boundary-border); +} + +.theme-nomad { + --gradient-start: var(--token-color-nomad-gradient-faint-stop); + --gradient-stop: var(--token-color-nomad-gradient-faint-start); + --inset-border-color: var(--token-color-nomad-border); +} + +.theme-waypoint { + --gradient-start: var(--token-color-waypoint-gradient-faint-stop); + --gradient-stop: var(--token-color-waypoint-gradient-faint-start); + --inset-border-color: var(--token-color-waypoint-border); +} + +.theme-vagrant { + --gradient-start: var(--token-color-vagrant-gradient-faint-stop); + --gradient-stop: var(--token-color-vagrant-gradient-faint-start); + --inset-border-color: var(--token-color-vagrant-border); +} diff --git a/src/views/open-api-docs-view-v2/components/sidebar-link/index.tsx b/src/views/open-api-docs-view-v2/components/sidebar-link/index.tsx new file mode 100644 index 0000000000..5d4df324be --- /dev/null +++ b/src/views/open-api-docs-view-v2/components/sidebar-link/index.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// Components +import Link from '@components/link' +import Text from '@components/text' +// Types +import type { PropsWithChildren } from 'react' +import type { LinkProps } from '@components/link' +// Styles +import s from './style.module.css' +import classNames from 'classnames' + +/** + * Render a link element styled for use in our sidebar. + * + * Note: this component is based on the existing SidebarNavLinkItem. This + * component aims to render similar link elements through a more composable + * interface. We could in theory start to replace SidebarNavLinkItem with + * this component. For now, focus is on delivering API docs, so intent here + * is more narrow, hopefully this component will make the new API docs easier + * to maintain and iterate on by decoupling from our existing component. If + * we're happy with the pattern, then we could adopt it elsewhere. + * + * If further functionality from SidebarNavLinkItem is required in this + * context, it may be worth first trying to build the desired use case by + * composing existing pieces (such as SidebarLink and SidebarLinkText). + * For example, an "external link" component could be something like + * ``. + * + * If composed patterns are repeated _exactly_, and in a way where we'd + * definitely want to update all instances at once, then it may be worth + * creating a re-usable component that captures the composition pattern. + * + * A component that captures a common pattern of composition should _not_ need + * any conditional statements. The props interface should be very simple - the + * point being to _reduce_ the complexity of repeating the identical composed + * pattern. If "edge cases" need to be handled, then the consumer can "eject" + * from the composed pattern by copying and pasting the body of the composed + * component and making changes from there. + */ +export function SidebarLink({ + children, + /** + * We merge the className prop with our own styles. This allows consumers + * to target the `` element rendered by ``. + */ + className, + ...linkProps +}: PropsWithChildren) { + return ( + + {children} + + ) +} + +/** + * Render a span element with text styles that fit with SidebarLink. + */ +export function SidebarLinkText({ children }) { + return ( + + {children} + + ) +} diff --git a/src/views/open-api-docs-view-v2/components/sidebar-link/style.module.css b/src/views/open-api-docs-view-v2/components/sidebar-link/style.module.css new file mode 100644 index 0000000000..113bf0a7ee --- /dev/null +++ b/src/views/open-api-docs-view-v2/components/sidebar-link/style.module.css @@ -0,0 +1,27 @@ +.sidebarLink { + composes: g-focus-ring-from-box-shadow from global; + background-color: var(--token-color-surface-primary); + border-radius: 5px; + border: none; + color: var(--token-color-foreground-faint); + padding: 8px; + position: relative; + text-align: left; + width: 100%; + + &:focus { + color: var(--token-color-foreground-strong); + } + + /* Hover state includes background color change */ + &:hover { + color: var(--token-color-foreground-strong); + background-color: var(--token-color-palette-neutral-100); + } + + /* Highlight state for current page */ + &[aria-current='page'] { + color: var(--token-color-foreground-strong); + background-color: var(--token-color-palette-neutral-200); + } +} diff --git a/src/views/open-api-docs-view-v2/components/sidebar-resource-links/index.tsx b/src/views/open-api-docs-view-v2/components/sidebar-resource-links/index.tsx new file mode 100644 index 0000000000..f1b70bc216 --- /dev/null +++ b/src/views/open-api-docs-view-v2/components/sidebar-resource-links/index.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// Third-party +import classNames from 'classnames' +// Components +import { SidebarSectionHeading } from '@components/sidebar/components' +import { SidebarLink, SidebarLinkText } from '../sidebar-link' +import { SidebarLinkExternal } from '../sidebar-link-external' +// Styles +import s from './style.module.css' + +/** + * Render a list of sidebar links, each of which may or may not be external. + */ +export function SidebarResourceLinks({ + resourceLinks, +}: { + resourceLinks: { text: string; href: string; isExternal: boolean }[] +}) { + return ( +
    + +
  • +
      + {resourceLinks.map((item, index) => { + const { href, text, isExternal } = item + const key = `${href}-${index}` + if (isExternal) { + return ( + + {text} + + ) + } else { + return ( + + {text} + + ) + } + })} +
    +
  • +
+ ) +} diff --git a/src/views/open-api-docs-view-v2/components/sidebar-resource-links/style.module.css b/src/views/open-api-docs-view-v2/components/sidebar-resource-links/style.module.css new file mode 100644 index 0000000000..d68f247738 --- /dev/null +++ b/src/views/open-api-docs-view-v2/components/sidebar-resource-links/style.module.css @@ -0,0 +1,12 @@ +.sidebarLinkList { + display: flex; + flex-direction: column; + gap: 2px; +} + +.listResetStyles { + list-style: none; + margin: 0; + padding: 0; + width: 100%; +} diff --git a/src/views/open-api-docs-view-v2/components/sidebar/index.tsx b/src/views/open-api-docs-view-v2/components/sidebar/index.tsx new file mode 100644 index 0000000000..11da90296b --- /dev/null +++ b/src/views/open-api-docs-view-v2/components/sidebar/index.tsx @@ -0,0 +1,81 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// Third-party +import classNames from 'classnames' +import { Fragment } from 'react' +// Components +import { + SidebarHorizontalRule, + SidebarSectionHeading, +} from '@components/sidebar/components' +import { SidebarLink, SidebarLinkText } from '../sidebar-link' +import { SidebarLinkWithProductIcon } from '../sidebar-link-with-product-icon' +import { SidebarLinkExternal } from '../sidebar-link-external' +// Types +import type { ProductSlug } from 'types/products' +// Styles +import s from './style.module.css' + +/** + * Renders sidebar contents for the OpenAPI V2 docs view. + */ +export function OpenApiV2SidebarContents({ + landingLink, + operationLinkGroups, +}: { + landingLink: { + text: string + href: string + isActive: boolean + theme: ProductSlug + } + operationLinkGroups: { + text: string + items: { text: string; href: string; isActive: boolean }[] + }[] +}) { + return ( +
    + {/* Fancy icon link, meant for landing view */} + + {/* Operation links, in groups */} + {operationLinkGroups.map((group) => { + return ( + + +
      + +
    • +
        + {group.items.map((item, index) => { + const { href, text, isActive } = item + const key = `${href}-${index}` + return ( + + {text} + + ) + })} +
      +
    • +
    +
    + ) + })} +
+ ) +} diff --git a/src/views/open-api-docs-view-v2/components/sidebar/style.module.css b/src/views/open-api-docs-view-v2/components/sidebar/style.module.css new file mode 100644 index 0000000000..fa555129b7 --- /dev/null +++ b/src/views/open-api-docs-view-v2/components/sidebar/style.module.css @@ -0,0 +1,12 @@ +.listResetStyles { + list-style: none; + margin: 0; + padding: 0; + width: 100%; +} + +.sidebarLinkList { + display: flex; + flex-direction: column; + gap: 2px; +} diff --git a/src/views/open-api-docs-view-v2/index.tsx b/src/views/open-api-docs-view-v2/index.tsx index 9b77b06dda..4eb6cec6eb 100644 --- a/src/views/open-api-docs-view-v2/index.tsx +++ b/src/views/open-api-docs-view-v2/index.tsx @@ -6,8 +6,12 @@ // Layout import SidebarLayout from 'layouts/sidebar-layout' // Components +import { OpenApiV2SidebarContents } from './components/sidebar' +import { SidebarHorizontalRule } from '@components/sidebar/components' +import { SidebarResourceLinks } from './components/sidebar-resource-links' import LandingContent from './components/landing-content' import OperationContent from './components/operation-content' +import SidebarBackToLink from '@components/sidebar/components/sidebar-back-to-link' // Types import type { OpenApiDocsViewV2Props } from './types' @@ -19,40 +23,32 @@ import type { OpenApiDocsViewV2Props } from './types' */ export default function OpenApiDocsViewV2({ basePath, - navItems, + backToLink, + landingLink, + operationLinkGroups, + resourceLinks, ...restProps }: OpenApiDocsViewV2Props) { + // return ( - {navItems.map((navItem) => { - if (!('fullPath' in navItem)) { - return null - } - return ( -
  • - - {navItem.title} - -
  • - ) - })} - + <> + {/* Back to link, meant for navigating up a level of context */} + {backToLink ? ( + + ) : null} + + {resourceLinks.length > 0 ? ( + <> + + + + ) : null} + } /** * TODO: implement mobile menu. May be tempting to try to re-use the data diff --git a/src/views/open-api-docs-view-v2/schema-transforms/schema-transform-shorten-hcp.ts b/src/views/open-api-docs-view-v2/schema-transforms/schema-transform-shorten-hcp.ts new file mode 100644 index 0000000000..f0a61b961d --- /dev/null +++ b/src/views/open-api-docs-view-v2/schema-transforms/schema-transform-shorten-hcp.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import type { OpenAPIV3 } from 'openapi-types' +import { schemaTransformTitle } from './schema-transform-title' +import { shortenHcp } from '../utils/shorten-hcp' + +/** + * Given an OpenAPI schema document, + * Return the document with the title modified, with any instances of + * "HashiCorp Cloud Platform" replaced with "HCP". + */ +export function schemaTransformShortenHcp( + schemaData: OpenAPIV3.Document +): OpenAPIV3.Document { + return schemaTransformTitle(schemaData, shortenHcp) +} diff --git a/src/views/open-api-docs-view-v2/schema-transforms/schema-transform-title.ts b/src/views/open-api-docs-view-v2/schema-transforms/schema-transform-title.ts new file mode 100644 index 0000000000..d1ed23cab0 --- /dev/null +++ b/src/views/open-api-docs-view-v2/schema-transforms/schema-transform-title.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import type { OpenAPIV3 } from 'openapi-types' + +/** + * Modifies an incoming `schemaData`, which is expected to be a valid OpenAPI + * schema, in order to make adjustments to the `info.title` property. + */ +export function schemaTransformTitle( + schemaData: OpenAPIV3.Document, + modifyFn: (title: string) => string +): OpenAPIV3.Document { + return { + ...schemaData, + info: { ...schemaData.info, title: modifyFn(schemaData.info.title) }, + } +} diff --git a/src/views/open-api-docs-view-v2/server.ts b/src/views/open-api-docs-view-v2/server.ts index 84b4533cbb..2ced2b6cdd 100644 --- a/src/views/open-api-docs-view-v2/server.ts +++ b/src/views/open-api-docs-view-v2/server.ts @@ -3,41 +3,63 @@ * SPDX-License-Identifier: MPL-2.0 */ -import { getNavItems } from './utils/get-nav-items' import { parseAndValidateOpenApiSchema } from 'lib/api-docs/parse-and-validate-open-api-schema' +import { stripUndefinedProperties } from 'lib/strip-undefined-props' +import isAbsoluteUrl from 'lib/is-absolute-url' // Utils import getOperationContentProps from './components/operation-content/server' import getLandingContentProps from './components/landing-content/server' +import { + getOperationObjects, + OperationObject, +} from './utils/get-operation-objects' +import { wordBreakCamelCase } from './utils/word-break-camel-case' +import { groupItemsByKey } from './utils/group-items-by-key' // Types import type { OpenApiDocsViewV2Props, SharedProps, + OpenApiDocsViewV2Config, } from 'views/open-api-docs-view-v2/types' +/** + * Build static props for an OpenAPI docs view. + * + * There are two main views: + * - Landing view, for the basePath, when no operationSlug is provided + * - Operation view, for the specific operationSlug that's been provided + */ export async function getStaticProps({ basePath, - operationSlug, + getOperationGroupKey = (o: OperationObject) => + (o.tags.length && o.tags[0]) ?? 'Other', openApiJsonString, -}: { - basePath: string - operationSlug?: string - openApiJsonString: string -}): Promise { + operationSlug, + schemaTransforms, + theme = 'hcp', + backToLink, + resourceLinks = [], +}: OpenApiDocsViewV2Config): Promise { /** * Fetch, parse, and validate the OpenAPI schema for this version. */ - const schemaData = await parseAndValidateOpenApiSchema(openApiJsonString) + const rawSchemaData = await parseAndValidateOpenApiSchema(openApiJsonString) + + /** + * Apply any schema transforms. + */ + let schemaData = rawSchemaData + for (const schemaTransformFunction of schemaTransforms ?? []) { + schemaData = schemaTransformFunction(schemaData) + } /** - * Gather props common to both the "landing" and "operation" views, namely: - * - * - basePath - base path from the dev dot URL, eg `/hcp/some-api-docs` - * - navItems - links for the sidebar - * * TODO: add breadcrumb bar. Or, could be done separately for each view, * if we have well-abstracted composable functions to build breadcrumbs? * (maybe already started, build breadcrumb from URL path segments?) - * + */ + + /** * TODO: version selector. Probably needs to come a little later, but * seems like something that would be duplicated pretty exactly between * the landing and operation views. That being said, maybe there's an @@ -47,8 +69,45 @@ export async function getStaticProps({ * complex version may end up meaning significant differences in the logic * to generate the version selector depending on landing vs operation view. */ - const navItems = getNavItems(basePath, operationSlug, schemaData) - const sharedProps: SharedProps = { basePath, navItems } + + /** + * Build links for the sidebar. + */ + const operationObjects = getOperationObjects(schemaData) + const operationGroups = groupItemsByKey( + operationObjects, + getOperationGroupKey + ) + const landingLink = { + theme, + text: schemaData.info.title, + href: basePath, + isActive: !operationSlug, + } + const operationLinkGroups = operationGroups.map((group) => ({ + // Note: we word break to avoid long strings breaking the sidebar layout + text: wordBreakCamelCase(group.key), + items: group.items.map(({ operationId }) => { + return { + text: wordBreakCamelCase(operationId), + href: `${basePath}/${operationId}`, + isActive: operationSlug === operationId, + } + }), + })) + + /** + * Gather props shared between the landing and individual operation views. + */ + const sharedProps: SharedProps = { + basePath, + backToLink, + landingLink, + operationLinkGroups, + resourceLinks: resourceLinks.map((item) => { + return { ...item, isExternal: isAbsoluteUrl(item.href) } + }), + } /** * If we have an operation slug, build and return operation view props. @@ -59,9 +118,9 @@ export async function getStaticProps({ operationSlug, schemaData ) - return { ...sharedProps, operationContentProps } + return stripUndefinedProperties({ ...sharedProps, operationContentProps }) } else { const landingContentProps = await getLandingContentProps(schemaData) - return { ...sharedProps, landingContentProps } + return stripUndefinedProperties({ ...sharedProps, landingContentProps }) } } diff --git a/src/views/open-api-docs-view-v2/types.ts b/src/views/open-api-docs-view-v2/types.ts index 86e8c6b0be..928cebdb79 100644 --- a/src/views/open-api-docs-view-v2/types.ts +++ b/src/views/open-api-docs-view-v2/types.ts @@ -3,28 +3,41 @@ * SPDX-License-Identifier: MPL-2.0 */ -import { OperationContentProps } from './components/operation-content' -import { LandingContentProps } from './components/landing-content' - -/** - * Nav items are used to render the sidebar. - * - * TODO: will likely need to be expanded once a more fully-featured sidebar - * is constructed. Older OpenAPI docs view may be useful for prior art, though - * we probably don't need to feel too tied to it. - */ -export type OpenApiNavItem = { - title: string - fullPath: string - isActive: boolean -} +// Types +import type { LandingContentProps } from './components/landing-content' +import type { OpenAPIV3 } from 'openapi-types' +import type { OperationContentProps } from './components/operation-content' +import type { OperationObject } from './utils/get-operation-objects' +import type { ProductSlug } from 'types/products' /** * Shared props are common to both the "landing" and "operation" views. */ export interface SharedProps { basePath: string - navItems: OpenApiNavItem[] + backToLink: { + text: string + href: string + } + landingLink: { + text: string + href: string + isActive: boolean + theme: ProductSlug + } + operationLinkGroups: { + text: string + items: { + text: string + href: string + isActive: boolean + }[] + }[] + resourceLinks?: { + text: string + href: string + isExternal: boolean + }[] } /** @@ -35,3 +48,66 @@ export interface SharedProps { export type OpenApiDocsViewV2Props = | (SharedProps & { operationContentProps: OperationContentProps }) | (SharedProps & { landingContentProps: LandingContentProps }) + +/** + * OpenApiDocsViewV2Config is used to set up and configure a set of + * OpenAPI docs views. The options provided here are meant to allow + * full control over all the possible variations of an OpenAPI docs view + * we might want to render. + * + * We want to keep these configuration options as simple as possible, as + * they must be configured for each new set of OpenAPI specs that we add. + */ +export interface OpenApiDocsViewV2Config { + /** + * The URL path at which the docs are located. For example, the URL + * `developer.hashicorp.com/hcp/api-docs/hcp-vault-secrets` has the basePath + * `/hcp/api-docs/hcp-vault-secrets`. */ + basePath: string + /** + * Optional link to move up a level of context. Typically used to link + * back to higher level documentation for the subject of the OpenAPI docs. k + */ + backToLink?: { + text: string + href: string + } + /** + * Optional function to control how operation objects are grouped in the + * sidebar. By default, getOperationGroupKey looks for the first `tag` + * value of each operation, such that operations are grouped by their + * first tag. + */ + getOperationGroupKey?: (o: OperationObject) => string + /** + * The OpenAPI schema as a JSON string. + */ + openApiJsonString: string + /** + * Optional operation slug to render a specific operation view. + */ + operationSlug?: string + /** + * Optional theme value to add specific product chrome to the view. + * For example, when the `vault` value is provided, the Vault logo will + * be shown in the sidebar and by the title of the OpenAPI spec. + */ + theme?: ProductSlug + /** + * Optional array of functions to transform the OpenAPI schema before + * rendering the view. This allows content to be manipulated before + * we render the view. While in most cases it's ideal to make content + * changes at the content source (typically the `hashicorp/hcp-specs` repo), + * sometimes our development timeline or other technical constraints + * necessitate programmatically making changes just before rendering. + */ + schemaTransforms?: ((s: OpenAPIV3.Document) => OpenAPIV3.Document)[] + /** + * Optional array of resource item links to render at the bottom + * of the sidebar. External links open in a new tab. + */ + resourceLinks?: { + text: string + href: string + }[] +} diff --git a/src/views/open-api-docs-view-v2/utils/get-nav-items.ts b/src/views/open-api-docs-view-v2/utils/get-nav-items.ts deleted file mode 100644 index f3c839c43b..0000000000 --- a/src/views/open-api-docs-view-v2/utils/get-nav-items.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -// Utils -import { wordBreakCamelCase } from './word-break-camel-case' -// Types -import type { OpenAPIV3 } from 'openapi-types' -import type { OpenApiNavItem } from '../types' - -/** - * Build nav items for the OpenAPI view sidebar. - * - * We expect the sidebar navigation to be consistent across the landing view - * and the individual operation views. - * - * TODO: this is mostly placeholder for now, needs to be properly implemented. - */ -export function getNavItems( - baseUrl: string, - operationSlug, - openApiDocument: OpenAPIV3.Document -): OpenApiNavItem[] { - /** - * Initialize the navItems array with a link to the landing view - */ - const navItems: OpenApiNavItem[] = [ - { - title: 'Landing', - fullPath: baseUrl, - isActive: !operationSlug, - }, - ] - - /** - * Iterate over all paths in the openApiDocument. - * Each path can support many operations through different request types. - */ - for (const [_path, pathItemObject] of Object.entries(openApiDocument.paths)) { - for (const [type, operation] of Object.entries(pathItemObject)) { - // String values are apparently possible, but not sure how to support them - if (typeof operation === 'string') { - continue - } - // We only want operation objects. - if (!('operationId' in operation)) { - continue - } - - // Push a nav item for this operation - const { operationId } = operation - navItems.push({ - title: `[${type}] ${wordBreakCamelCase(operationId)}`, - fullPath: `${baseUrl}/${operationId}`, - isActive: operationSlug === operationId, - }) - } - } - - return navItems -} diff --git a/src/views/open-api-docs-view-v2/utils/get-operation-group-key-from-path.ts b/src/views/open-api-docs-view-v2/utils/get-operation-group-key-from-path.ts new file mode 100644 index 0000000000..609d4c867c --- /dev/null +++ b/src/views/open-api-docs-view-v2/utils/get-operation-group-key-from-path.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { truncateHcpOperationPath } from './truncate-hcp-operation-path' +import type { OperationObject } from './get-operation-objects' + +/** + * Given an operation object, derive a string representing the + * first two segments of the operation's path, and + * Return the string for use in grouping the operation with other + * operations that share the first two path segments. + */ +export function getOperationGroupKeyFromPath(o: OperationObject) { + const truncatedPath = truncateHcpOperationPath(o.path) + return truncatedPath.split('/').slice(0, 3).join('/') +} diff --git a/src/views/open-api-docs-view-v2/utils/get-operation-objects.ts b/src/views/open-api-docs-view-v2/utils/get-operation-objects.ts new file mode 100644 index 0000000000..eb1a957a7d --- /dev/null +++ b/src/views/open-api-docs-view-v2/utils/get-operation-objects.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// Types +import type { OpenAPIV3 } from 'openapi-types' + +/** + * The OpenAPIV3 OperationObject type, with the `path` and `type` (ie GET, POST) + * associated with the operation. + */ +export type OperationObject = OpenAPIV3.OperationObject & { + path: string + type: string +} + +/** + * Given an OpenAPI document, build an array of operation objects, + * each including the properties defined for operations in the OpenAPI spec, + * as well as the `path` and `type` (ie GET, POST, etc) of the operation, and + * Return the array of operation objects. + */ +export function getOperationObjects( + openApiDocument: OpenAPIV3.Document +): OperationObject[] { + // + const operationObjects = [] + // Iterate over the openApiDocument paths, extracting operations from each. + for (const [path, pathItemObject] of Object.entries(openApiDocument.paths)) { + // Iterate over types (GET, POST, etc) in the pathItemObject + for (const [type, operation] of Object.entries(pathItemObject)) { + // String values are possible for operations, I think when the value + // is a reference, but we don't handle them in this context, as we + // expect our document to already be fully dereferenced. + if (typeof operation === 'string') { + continue + } + // Add the valid operation object to an array, including path and type + operationObjects.push({ path, type, ...operation }) + } + } + // Return the list of operation objects + return operationObjects +} diff --git a/src/views/open-api-docs-view-v2/utils/group-items-by-key.test.ts b/src/views/open-api-docs-view-v2/utils/group-items-by-key.test.ts new file mode 100644 index 0000000000..50277902e8 --- /dev/null +++ b/src/views/open-api-docs-view-v2/utils/group-items-by-key.test.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { groupItemsByKey } from './group-items-by-key' + +describe('groupItemsByKey', () => { + it('groups items by a specified property', async () => { + // Define an array of items to test + const items = [ + { + path: '/resource-manager/organizations', + }, + { + path: '/resource-manager/organizations/{id}', + }, + ] + // Define a function to get a group key from each item + function getGroupKeyFromPath(item): string { + return item.path.split('/').slice(0, 3).join('/') + } + // Get the result of grouping the items, it should match what we expect + const result = groupItemsByKey(items, getGroupKeyFromPath) + const expected = [ + { + key: '/resource-manager/organizations', + items: [ + { + path: '/resource-manager/organizations', + }, + { + path: '/resource-manager/organizations/{id}', + }, + ], + }, + ] + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)) + }) +}) diff --git a/src/views/open-api-docs-view-v2/utils/group-items-by-key.ts b/src/views/open-api-docs-view-v2/utils/group-items-by-key.ts new file mode 100644 index 0000000000..1348d3e68f --- /dev/null +++ b/src/views/open-api-docs-view-v2/utils/group-items-by-key.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/** + * Given a flat array of items, and a getItemGroupKey function, + * Return an array of item groups, with item groups being constructed + * based on keys derived from the provided getItemGroupKey function. + */ +export function groupItemsByKey( + items: Item[], + getItemGroupKey: (item: Item) => string +): { key: string; items: Item[] }[] { + // Set up an array to hold the item groups + const itemGroups: { key: string; items: Item[] }[] = [] + // Iterate over our items, adding them to groups as we go + for (const item of items) { + const groupKey = getItemGroupKey(item) + const existingGroup = itemGroups.find((g) => g.key === groupKey) + if (existingGroup) { + // If we already have an existing group, add the item to it + existingGroup.items.push(item) + } else { + // Otherwise, start a new group item + itemGroups.push({ + key: groupKey, + items: [item], + }) + } + } + // Return the array of item groups + return itemGroups +} diff --git a/src/views/open-api-docs-view-v2/utils/shorten-hcp.ts b/src/views/open-api-docs-view-v2/utils/shorten-hcp.ts new file mode 100644 index 0000000000..52ac2f014d --- /dev/null +++ b/src/views/open-api-docs-view-v2/utils/shorten-hcp.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/** + * Replaces "HashiCorp Cloud Platform" with "HCP" in the given string. + */ +export function shortenHcp(s: string): string { + return s.replace('HashiCorp Cloud Platform', 'HCP') +} diff --git a/src/views/open-api-docs-view-v2/utils/truncate-hcp-operation-path.ts b/src/views/open-api-docs-view-v2/utils/truncate-hcp-operation-path.ts new file mode 100644 index 0000000000..732d4f08d9 --- /dev/null +++ b/src/views/open-api-docs-view-v2/utils/truncate-hcp-operation-path.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/** + * Specific HCP service paths start with the service name, + * followed by a date-based version number, + * and always include "organization" and "project" parameters. + * + * We remove all of this prefix from the path for better grouping + * of operations within the specific spec. + * + * @example `/secrets/2023-06-13/organizations/{organization_id}/projects/{project_id}/apps` + * Or more generically: + * `///organizations/{organization_id}/projects/{project_id}` + */ +const SPECIFIC_SERVICE_PATTERN = + /(\/[a-z]*\/\d\d\d\d-\d\d-\d\d\/organizations\/\{[a-z_.]*\}\/projects\/\{[a-z_.]*\})/ + +/** + * Global HCP service paths start with the service name, + * and always include "organization" and "project" parameters. + * + * We remove the version prefix from the path for better grouping. + * + * @example "/2022-02-15/some-global-service/{parameter}/etc" + * Or more generically: + * `//...(anything)` + */ +const GLOBAL_SERVICE_PATTERN = /\/\d\d\d\d-\d\d-\d\d/ + +/** + * Truncates HCP operation paths for brevity, removing the service, version, + * organization, and project prefixes for specific individual services, and + * removing the version prefix for global services. + */ +export function truncateHcpOperationPath(path: string) { + return path + .replace(SPECIFIC_SERVICE_PATTERN, '') + .replace(GLOBAL_SERVICE_PATTERN, '') +}