Add generated API docs to website (#537)

* Add dependencies for openapi-page component

* Add openapi-page component

* Use openapi-page component for /api-docs page

* add /api link to nav  (#570)

* website: remove superflous netlify config file

* website: misc clean up website/readme
- remove netlify badge
- run through prettier markdown fmt

* website: add /api docs to nav
- adds /api-docs => /api rewrite
- updates open-api component base api path to be /api
- adds .html rewrite for good measure

* Fix currentPath highlight issue in subnav

* Clean up inline styles in operation-object

* Remove React imports

* Remove api-docs rewrite

Co-authored-by: Kyle MacDonald <kylemac@users.noreply.github.com>
This commit is contained in:
Zachary ShiltonandKyle MacDonald authored and GitHub committed 2020-10-09 13:26:32 -07:00
1 parent c701b79122
commit 1cc8737540
25 files changed
+1250 -31

No files matched your search

+21 -11
View File
@@ -1,8 +1,6 @@
# Boundary Documentation Website
[![Netlify Status](https://img.shields.io/netlify/<NETLIFY-API-ID>?style=flat-square)](https://app.netlify.com/sites/<NETLIFY-SLUG>/deploys)
This subdirectory contains the entire source for the [Boundary Website](https://boundaryproject.io). This is a [NextJS](https://nextjs.org/) project, which builds a static site from these source files.
This subdirectory contains the entire source for the [Boundary Website](https://boundaryproject.io/). This is a [NextJS](https://nextjs.org/) project, which builds a static site from these source files.
<!--
This readme file contains several blocks of generated text, to make it easier to share common information
@@ -15,14 +13,26 @@ This subdirectory contains the entire source for the [Boundary Website](https://
## Table of Contents
- [Contributions](#contributions-welcome)
- [Running the Site Locally](#running-the-site-locally)
- [Editing Markdown Content](#editing-markdown-content)
- [Editing Navigation Sidebars](#editing-navigation-sidebars)
- [Changing the Release Version](#changing-the-release-version)
- [Redirects](#redirects)
- [Browser Support](#browser-support)
- [Deployment](#deployment)
- [Boundary Documentation Website](#boundary-documentation-website)
- [Table of Contents](#table-of-contents)
- [Contributions Welcome!](#contributions-welcome)
- [Running the Site Locally](#running-the-site-locally)
- [Editing Markdown Content](#editing-markdown-content)
- [Creating New Pages](#creating-new-pages)
- [Markdown Enhancements](#markdown-enhancements)
- [Custom Components](#custom-components)
- [Tabs](#tabs)
- [Enterprise Alert](#enterprise-alert)
- [Other Components](#other-components)
- [Syntax Highlighting](#syntax-highlighting)
- [Editing Navigation Sidebars](#editing-navigation-sidebars)
- [Index-less Categories](#index-less-categories)
- [Custom or External Links](#custom-or-external-links)
- [Changing the Release Version](#changing-the-release-version)
- [Displaying a Prerelease](#displaying-a-prerelease)
- [Redirects](#redirects)
- [Browser Support](#browser-support)
- [Deployment](#deployment)
<!-- BEGIN: contributions -->
<!-- Generated text, do not edit directly -->
+96
View File
@@ -0,0 +1,96 @@
import { useState, useRef } from 'react'
import { useRouter } from 'next/router'
import Head from 'next/head'
import Link from 'next/link'
import OperationObject from './partials/operation-object'
import DocsPage from '@hashicorp/react-docs-page'
import { getPathsFromSchema, getPropsForPage } from './utils/routing-utils'
import styles from './openapi-page.module.css'
import useOnClickOutside from 'lib/hooks/use-on-click-outside'
function OpenApiPage({
info,
operationCategory,
sidenavOrder,
productName,
productSlug,
pathFromRoot,
}) {
const router = useRouter()
const operationsRef = useRef(null)
const [expandedOperations, setExpandedOperations] = useState([])
useOnClickOutside(operationsRef, () => setExpandedOperations([]))
function setOperationState(slug, isExpanded) {
const newStates = expandedOperations.filter((s) => s !== slug)
if (isExpanded) newStates.push(slug)
setExpandedOperations(newStates)
}
const pageTitle = operationCategory ? operationCategory.name : info.title
return (
<div className={styles.root} data-theme={productSlug}>
<DocsPage
head={{
is: Head,
title: `${pageTitle} | ${productName} by HashiCorp`,
description: info.description,
siteName: `${productName} by HashiCorp`,
}}
product="vault"
resourceURL={`https://github.com/hashicorp/${productSlug}`}
sidenav={{
Link,
currentPage: router.asPath,
category: pathFromRoot,
disableFilter: true,
order: sidenavOrder,
data: [],
}}
>
{operationCategory ? (
<div>
<p className={`${styles.pageHeading} g-type-display-2`}>
{info.title}
</p>
<h1 className={`${styles.categoryHeading} g-type-display-4`}>
{operationCategory.name}
</h1>
<div ref={operationsRef}>
{operationCategory.operations.map((op) => {
const isExpanded =
expandedOperations.indexOf(op.operationId) !== -1
return (
<OperationObject
key={op.__type + op.__path}
path={op.__path}
type={op.__type}
data={op}
isCollapsed={!isExpanded}
setIsCollapsed={(isCollapsed) =>
setOperationState(op.operationId, !isCollapsed)
}
/>
)
})}
</div>
</div>
) : (
<div>
<h1 className={`${styles.pageHeading} g-type-display-2`}>
{info.title}
</h1>
<p className={`${styles.landingPlaceholder} g-type-body-long`}>
Select a service from the sidebar.
</p>
</div>
)}
</DocsPage>
</div>
)
}
export { getPathsFromSchema, getPropsForPage }
export default OpenApiPage
@@ -0,0 +1,32 @@
import PropTypes from 'prop-types'
import OpenAPIPage from '.'
function OpenAPIPageProps(props) {
return <OpenAPIPage {...props} />
}
OpenAPIPageProps.propTypes = {
/** Information about the API, pulled from the schema's [Info Object](https://swagger.io/specification/v2/#info-object) */
info: PropTypes.shape({
title: PropTypes.string.isRequired,
description: PropTypes.string,
version: PropTypes.string,
}),
/** The name of the associated HashiCopr product, used in page metadata */
productName: PropTypes.string,
/** The slug of the associated HashiCorp product, used for theming. Only supports "boundary" or a default theme at present.*/
productSlug: PropTypes.oneOf(['boundary']),
/** An object that determines which operations will be shown on the page */
operationCategory: PropTypes.shape({
/** The name of the operation "category", used as the page title */
name: PropTypes.string,
/** The slug-ified name, used as the page's location */
slug: PropTypes.string,
/** An array of [Operation Objects](https://swagger.io/specification/v2/#operation-object) to be displayed on the page */
operations: PropTypes.arrayOf(PropTypes.object),
}),
/** ["order"](https://github.com/hashicorp/react-components/blob/11d7bda7d518bc675ab0033067d43057e21f3b77/packages/docs-sidenav/index.js#L9) data generated to be passed directly to our DocsSidenav component */
sidenavOrderData: PropTypes.object,
}
export default OpenAPIPageProps
@@ -0,0 +1,18 @@
.root {
/* currently only supports "boundary", may want to extend to support other product themes */
&[data-theme='boundary'] {
--brand: var(--boundary);
}
& .pageHeading {
margin: 0;
}
& .categoryHeading {
margin: 32px 0 16px 0;
}
& .landingPlaceholder {
color: var(--gray-2);
}
}
@@ -0,0 +1,23 @@
function ChevronDownBlack({ title, ...props }) {
return (
<svg
width={props.width ? props.width : props.height ? 1 * props.height : 24}
height={props.height ? props.height : props.width ? props.width / 1 : 24}
viewBox="0 0 24 24"
fill="none"
role="presentation"
{...props}
>
{title ? <title>{title}</title> : null}
<path
d="M6 9l6 6 6-6"
stroke={props.color || '#000'}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default ChevronDownBlack
@@ -0,0 +1,18 @@
.outer {
overflow: hidden;
height: 0;
&[data-useanimation='true'] {
transition: height 0.4s;
}
}
.inner {
opacity: 1;
&[data-iscollapsed='true'] {
opacity: 0;
}
&[data-useanimation='true'] {
transition: opacity 0.4s;
}
}
@@ -0,0 +1,81 @@
import { Component } from 'react'
import styles from './collapsible.module.css'
/*
Collapsible content block, receives children and a 'isCollapsed' prop,
smoothly animates from collapsed (height 0) to expanded (height auto)
*/
export default class Collapsible extends Component {
constructor(props) {
super(props)
this.animateHeight = this.animateHeight.bind(this)
}
componentDidMount() {
this.animateHeight()
}
componentDidUpdate(prevProps) {
// Only update if isCollapsed prop has changed
if (this.props.isCollapsed === prevProps.isCollapsed) return
this.animateHeight()
}
animateHeight() {
function handleTransitionEnd(event) {
const elem = event.target
const elemStyle = getComputedStyle(elem)
const isCollapsed = elem.getAttribute('data-iscollapsed')
if (event.propertyName !== 'height') return
const innerElem = event.target.firstChild
const innerElemStyle = getComputedStyle(innerElem)
if (isCollapsed === 'false') {
elem.style.height = 'auto'
innerElem.style.transitionDelay = '0s'
} else if (isCollapsed === 'true') {
const heightDurn = parseFloat(elemStyle['transition-duration'])
const opactiyDurn = parseFloat(innerElemStyle['transition-duration'])
innerElem.style.transitionDelay = `${heightDurn - opactiyDurn}s`
}
elem.removeEventListener('transitionend', handleTransitionEnd, false)
}
const { isCollapsed } = this.props
// Do not attempt update if container elem is invalid
const elem = this.containerRef
if (!elem) return
const elemStyle = getComputedStyle(elem)
if (isCollapsed) {
// Transition from auto to 0
elem.style.height = elemStyle.height
elem.offsetHeight // force repaint
elem.style.height = '0px'
elem.setAttribute('data-iscollapsed', 'true')
elem.addEventListener('transitionend', handleTransitionEnd, false)
} else {
// Transition from 0 to auto
const prevHeight = elem.style.height
elem.style.height = 'auto'
const endHeight = elemStyle.height
elem.style.height = prevHeight
elem.offsetHeight // force repaint
elem.style.height = endHeight
elem.setAttribute('data-iscollapsed', 'false')
elem.addEventListener('transitionend', handleTransitionEnd, false)
}
}
render() {
const { children, noAnimation } = this.props
return (
<div
className={styles.outer}
ref={(ref) => (this.containerRef = ref)}
data-useanimation={Boolean(!noAnimation)}
>
<div className={styles.inner} data-useanimation={Boolean(!noAnimation)}>
{children}
</div>
</div>
)
}
}
@@ -0,0 +1,154 @@
import Collapsible from '../collapsible'
import ResponseObject from '../response-object'
import PropertyObject from '../property-object'
import { capitalCase } from 'change-case'
import useHover from '../../../../lib/hooks/use-hover'
import SvgrChevronDown from '../chevron-down/black'
import styles from './operation-object.module.css'
function OperationObject({
data,
path,
type,
isHighlighted,
isCollapsed,
setIsCollapsed,
}) {
const [headerRef, isHeaderHovered] = useHover()
// const [isCollapsed, setIsCollapsed] = useState(true)
const { operationId, parameters, responses, summary } = data
const successResponse = responses['200']
const title = capitalCase(operationId.split('_').slice(1).join())
// Group parameter properties by type
const pathParams = parameters.filter((p) => p.in === 'path')
const queryParams = parameters.filter((p) => p.in === 'query')
const bodyParam = parameters.filter((p) => p.in === 'body')[0] // Note: we only accept a single "in=body" param
const bodyProps = bodyParam ? getBodyParamProps(bodyParam) : []
return (
<div className={styles.root} data-is-hovered={isHeaderHovered}>
<div
className={styles.header}
ref={headerRef}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className={styles.meta}>
<div
className={styles.title}
data-is-highlighted={isHighlighted}
data-is-hovered={isHeaderHovered}
>
{title}
</div>
<div className={styles.endpoint}>
<span className={styles.method} data-is-hovered={isHeaderHovered}>
{type.toUpperCase()}{' '}
</span>
<span className={styles.path}>{path}</span>
</div>
</div>
<div className={styles.toggleButton}>
<span className={styles.toggleText}>
{isCollapsed ? 'Expand' : 'Collapse'}
</span>
<span className={styles.toggleIcon} data-is-collapsed={isCollapsed}>
<SvgrChevronDown width={16} />
</span>
</div>
</div>
<Collapsible isCollapsed={isCollapsed}>
<div className={styles.details}>
<p className={styles.summary}>{summary}</p>
<TwoColumnLayout
columnOne={
<div>
<p className={styles.columnHeading}>Request</p>
{pathParams.length > 0 ? (
<Parameters title="Path Parameters" params={pathParams} />
) : null}
{queryParams.length > 0 ? (
<Parameters title="Query Parameters" params={queryParams} />
) : null}
{bodyProps.length > 0 ? (
<Parameters title="Body Parameters" params={bodyProps} />
) : null}
</div>
}
columnTwo={
<div>
<p className={styles.columnHeading}>Response</p>
{!!successResponse ? (
<div>
<p
className={`${styles.columnSectionHeading} g-type-label-strong`}
>
Successful Response
</p>
<ResponseObject data={successResponse} />
</div>
) : (
<p>No response has been defined.</p>
)}
</div>
}
/>
</div>
</Collapsible>
</div>
)
}
function getBodyParamProps(bodyParam) {
// We always expect the bodyParam to be an object,
// with a schema which defines the body properties.
if (!bodyParam.schema || !bodyParam.schema.properties) return []
// We flatten these properties to avoid showing a
// "collapsed object" UI under the "Body Parameters" section,
// which would be a bit redundant and annoying to have to expand
const bodyPropsObj = bodyParam.schema.properties
const bodyProps = Object.keys(bodyPropsObj).reduce((acc, key) => {
const data = Object.assign({}, bodyPropsObj[key])
// We need the property name. This is usually be handled by "key" in an object,
// but we're flattening the object so we need to make sure it's there
data.name = key
if (!data.readOnly) acc.push(data)
return acc
}, [])
return bodyProps
}
function TwoColumnLayout({ columnOne, columnTwo }) {
return (
<div className={styles.twoColumnLayout}>
<div>{columnOne}</div>
<div></div>
<div>{columnTwo}</div>
</div>
)
}
function Parameters({ title, params }) {
return (
<div>
<p className={`${styles.columnSectionHeading} g-type-label-strong`}>
{title}
</p>
{params.map((parameter, idx) => {
return (
<PropertyObject
key={parameter.name}
name={parameter.name}
data={parameter}
isFirstItem={idx === 0}
isLastItem={idx === params.length - 1}
/>
)
})}
</div>
)
}
export default OperationObject
@@ -0,0 +1,61 @@
import React from 'react'
import PropTypes from 'prop-types'
import OperationObject from '.'
function OperationObjectProps(props) {
return <OperationObject {...props} />
}
/** Displays [Operation Object](https://swagger.io/specification/v2/#operation-object) data to the user. */
OperationObjectProps.propTypes = {
/** The path to the endpoint where this operation is executed */
path: PropTypes.string.isRequired,
/** The type of operation */
type: PropTypes.oneOf([
'get',
'put',
'post',
'delete',
'options',
'head',
'patch',
]).isRequired,
/** A subset of [Operation Object](https://swagger.io/specification/v2/#operation-object) relevant to the UI */
data: PropTypes.shape({
/** Flag whether this operation is deprecated */
deprecated: PropTypes.bool,
/** A unique string used to identify the operation.*/
operationId: PropTypes.string,
/** Array of parameter objects. We group them by their "in" property, and pass them to the <PropertyObject /> component for rendering. */
parameters: PropTypes.arrayOf(
PropTypes.shape({
/** The name of the parameter. Parameter names are case sensitive. */
name: PropTypes.string.isRequired,
/** A brief, plain text description of the parameter. Note that [GitHub-flavor markdown](https://guides.github.com/features/mastering-markdown/#GitHub-flavored-markdown) is not currently supported. */
description: PropTypes.string,
/** Indicates whether this parameter is mandatory. */
required: PropTypes.boolean,
/** The location of the parameter. Note: `formData` and `header` are not currently supported in the UI. */
in: PropTypes.oneOf(['query', 'path', 'body']).isRequired,
/** Schema is used for "body" parameters. */
schema: PropTypes.object,
/** Type defines the parameter type, for non-"body" parameters. */
type: PropTypes.oneOf([
'array',
'boolean',
'integer',
'number',
'object',
'string',
]),
})
),
/** Responses object, keys are HTTP response codes */
responses: PropTypes.shape({
/** We only support "200" response codes, for now. This object is passed to the <ResponseObject /> component. */
200: PropTypes.object,
}),
}),
}
export default OperationObjectProps
@@ -0,0 +1,145 @@
.root {
border-bottom: 1px solid var(--gray-6);
margin: 0;
margin-top: -1px;
position: relative;
transition: border-color 0.2s;
&[data-is-hovered='true'] {
border-bottom-color: var(--brand);
z-index: 1;
}
}
.header {
display: flex;
position: relative;
padding: 16px 0;
&:hover {
cursor: pointer;
}
}
.meta {
width: 100%;
}
.title {
position: relative;
display: inline-block;
composes: g-type-body-x-strong from global;
color: var(--gray-2);
margin: 0 0 4px 0;
padding: 0;
&[data-is-highlighted='true'] {
&::before {
content: '\200B';
position: absolute;
top: -4px;
left: -8px;
right: -8px;
bottom: -4px;
background: var(--warning-l1);
z-index: -1;
border-radius: 8px;
}
}
&[data-is-hovered='true'] {
color: var(--brand);
}
}
.endpoint {
composes: g-type-code from global;
& .method {
color: var(--gray-4);
&[data-is-hovered='true'] {
color: var(--brand);
}
}
& .path {
color: var(--gray-3);
}
}
.toggleButton {
border: none;
color: var(--brand);
background: none;
display: flex;
align-items: center;
font-size: inherit;
margin: 0;
padding: 0 0 0 24px;
transition: color 0.2s;
& .toggleIcon {
width: 16px;
height: 16px;
margin-left: 8px;
position: relative;
top: 1px;
transition: transform 0.3s;
display: flex;
align-items: center;
justify-content: center;
& svg {
& [fill] {
fill: var(--brand);
}
& [stroke] {
stroke: var(--brand);
}
}
&[data-is-collapsed='false'] {
transform: rotate(-180deg);
}
}
&[data-is-hovered='true'] {
color: var(--brand);
}
}
.details {
padding-bottom: 80px;
& .summary {
color: var(--gray-2);
margin: 8px 0 24px 0;
}
& .columnHeading {
margin: 0 0 1em 0;
font-weight: bold;
}
& .columnSectionHeading {
margin: 0 !important; /* Needed to override ".g-content p" margins */
padding-bottom: 8px;
border-bottom: 1px solid var(--gray-6);
color: var(--gray-4);
}
}
.twoColumnLayout {
display: flex;
& > div:nth-child(1),
& > div:nth-child(3) {
position: relative;
width: calc(50% - 16px);
}
& > div:nth-child(2) {
width: 32px;
}
}
@@ -0,0 +1,91 @@
import { useState } from 'react'
import Collapsible from '../collapsible'
import SvgrChevronDown from '../chevron-down/black'
import styles from './property-object.module.css'
function PropertyObject({
name,
data,
isFirstItem,
isLastItem,
arrayDepth = 0,
}) {
const [isCollapsed, setIsCollapsed] = useState(true)
const isArray = data.type === 'array'
if (isArray)
return (
<PropertyObject
name={name}
data={data.items}
arrayDepth={arrayDepth + 1}
isFirstItem={isFirstItem}
isLastItem={isLastItem}
/>
)
const hasProperties = data.type === 'object' && Boolean(data.properties)
const typeArraySuffix =
arrayDepth > 0 ? arrayFrom(arrayDepth, '[]').join('') : ''
const typeString = `${data.type}${typeArraySuffix}`
return (
<div
className={styles.root}
data-is-first-item={isFirstItem}
data-is-last-item={isLastItem}
>
<code className={`${styles.name} g-type-code`}>{name}</code>{' '}
<code className={`${styles.typeString} g-type-code`}>{typeString}</code>{' '}
{data.required ? (
<span className={`${styles.requiredFlag} g-type-label-strong`}>
Required
</span>
) : null}
{data.title && (
<p className={`${styles.title} g-type-body-small`}>{data.title}</p>
)}
{data.description && (
<p className={`${styles.description} g-type-body-small`}>
{data.description}
</p>
)}
{hasProperties && (
<div>
<button
className={`${styles.toggleButton} g-type-body-small`}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<span className={styles.toggleIcon} data-is-collapsed={isCollapsed}>
<SvgrChevronDown width={16} />
</span>
{isCollapsed ? 'Show properties' : 'Hide properties'}
</button>
<Collapsible isCollapsed={isCollapsed}>
<div className={styles.propertiesContainer}>
{Object.keys(data.properties).map((propertyKey, idx) => {
return (
<PropertyObject
key={propertyKey}
name={propertyKey}
data={data.properties[propertyKey]}
isFirstItem={idx === 0}
isLastItem={idx === Object.keys(data.properties).length - 1}
/>
)
})}
</div>
</Collapsible>
</div>
)}
</div>
)
}
function arrayFrom(length, value = null) {
let array = []
for (var i = 0; i < length; i++) {
array.push(value)
}
return array
}
export default PropertyObject
@@ -0,0 +1,85 @@
.root {
border-top: 1px solid var(--gray-6);
border-bottom: 1px solid var(--gray-6);
padding: 16px 0;
margin: -1px 0 0 0;
&[data-is-first-item='true'] {
border-top: none;
}
&[data-is-last-item='true'] {
border-bottom: none;
}
& .name {
color: var(--brand);
background: rgba(29, 30, 35, 0.07);
border-radius: 3px;
padding: 0.3em 0.625em;
}
& .typeString {
color: var(--gray-2);
background: none;
padding: 0;
margin-left: 2px;
}
& .requiredFlag {
color: var(--danger-d1);
margin-left: 4px;
}
& .title,
& .description {
color: var(--gray-3);
margin: 8px 0 0 0;
}
& .toggleButton {
border: 1px solid red;
border: none;
color: var(--brand);
background: none;
display: flex;
align-items: center;
margin: 8px 0 0 0;
padding: 0;
& .toggleIcon {
position: relative;
top: 1px;
width: 16px;
height: 16px;
margin-right: 8px;
transition: transform 0.3s;
display: flex;
align-items: center;
justify-content: center;
& svg {
& [fill] {
fill: var(--brand);
}
& [stroke] {
stroke: var(--brand);
}
}
&[data-is-collapsed='false'] {
transform: rotate(-180deg);
}
}
&:hover {
cursor: pointer;
}
}
& .propertiesContainer {
margin: 8px 0 0 8px;
padding-left: 16px;
border-left: 1px solid var(--gray-6);
}
}
@@ -0,0 +1,27 @@
import PropertyObject from '../property-object'
function ResponseObject({ data }) {
// `schema` can be empty, which means the response does not return content
// We currently only support object responses (ie those that have schema.properties) in the UI
// Ref: https://swagger.io/specification/v2/#response-object
if (!data || !data.schema || !data.schema.properties) {
return <div>No content.</div>
}
return (
<div>
{Object.keys(data.schema.properties).map((propertyKey, idx) => {
return (
<PropertyObject
key={propertyKey}
name={propertyKey}
data={data.schema.properties[propertyKey]}
isFirstItem={idx === 0}
isLastItem={idx === Object.keys(data.schema.properties).length - 1}
/>
)
})}
</div>
)
}
export default ResponseObject
@@ -0,0 +1,16 @@
import PropTypes from 'prop-types'
import ResponseObject from '.'
function ResponseObjectProps(props) {
return <ResponseObject {...props} />
}
ResponseObjectProps.propTypes = {
/** [Response Object](https://swagger.io/specification/v2/#response-object) data. */
data: PropTypes.shape({
/** A [Schema Object](https://swagger.io/specification/v2/#schema-object) that describes the response. Note that we currently only support objects (with schema.properties) responses in the UI. */
schema: PropTypes.object,
}),
}
export default ResponseObjectProps
@@ -0,0 +1,117 @@
import { capitalCase } from 'change-case'
import slugify from '@hashicorp/remark-plugins/generate_slug'
/* Gieven a schema, current service slug, and parentPath, return all props needed for an openapi component page */
export function getPropsForPage(schema, params) {
// parse the data we'll show to the user from the schema
const operationObjects = getOperationObjects(schema)
const serviceIds = getServiceIds(operationObjects)
// info and sidenav data are needed on all pages
const info = schema.info
const sidenavOrder = getSidenavData(serviceIds)
// If there's no "page" param, then this is the landing page
const isLanding = !params || !params.page || params.page.length == 0
// Otherwise, we should have an operationCategory that matches the slug-ified ID from the URL path
const operationCategory = isLanding
? false
: serviceIds
.filter((id) => getServiceSlug(id) === params.page[0])
.map((serviceId) => {
const name = capitalCase(serviceId)
const slug = getServiceSlug(serviceId)
const operations = operationObjects.filter(
(o) => getServiceId(o.operationId) === serviceId
)
return { name, slug, operations }
})[0]
return { info, sidenavOrder, operationCategory }
}
/* Given a schema, return all the paths we'll render for our openapi generated docs */
export function getPathsFromSchema(schema) {
// Assign each operation category to a URL using its slug-ified ID
const operationObjects = getOperationObjects(schema)
const slugs = getServiceIds(operationObjects).map(getServiceSlug)
// We need a path for each "service"
const paths = slugs.map((slug) => ({ params: { page: [slug] } }))
// We also push a path for an "/" index page
paths.push({ params: { page: [] } })
return paths
}
/* Given a list of service ids, return an object suitable to pass to <DocsSidenav />
to render a flat list of services */
function getSidenavData(serviceIds) {
const order = serviceIds.map((serviceId) => {
return {
sidebar_title: capitalCase(serviceId),
indexData: true,
path: getServiceSlug(serviceId),
}
})
return order
}
/* Given a serviceId, which is typically PascalCase string, return a slugified kebab-case string (ie: pascal-case rather than pascalcase) */
function getServiceSlug(serviceId) {
return slugify(capitalCase(serviceId))
}
/* Given an operationId, return the "serviceId" */
function getServiceId(operationId) {
// We expect operationIds to have two parts, separated by an underscore
// The "serviceId" is the first part of the value
return operationId.split('_')[0]
}
/* Given a schema, return an array of unique operation "category" strings */
export function getServiceIds(operationObjects) {
const operationIdCategories = operationObjects
.map((o) => getServiceId(o.operationId))
.sort()
// Several related operationIds may have the same "category" part,
// so we filter for unique values before returning
return filterUnique(operationIdCategories)
}
/* Given an array of values, return an array without duplicate items */
function filterUnique(array) {
return array.filter((value, idx) => array.indexOf(value) === idx)
}
/* Given a schema, return a flattened list of operation objects */
function getOperationObjects(schema) {
const pathItemObjects = Object.keys(schema.paths).reduce((acc, path) => {
acc.push({ __path: path, ...schema.paths[path] })
return acc
}, [])
const operationObjects = pathItemObjects.reduce((acc, pathItemObject) => {
// Each path can support many operations through different request types
const requestTypes = [
'get',
'put',
'post',
'delete',
'options',
'head',
'patch',
]
const pathOperations = requestTypes.reduce((acc, type) => {
// Not all paths will support every request type
if (!pathItemObject[type]) return acc
// If the request type is supported, push the associated operation
acc.push({
__type: type,
__path: pathItemObject.__path,
...pathItemObject[type],
})
return acc
}, [])
return acc.concat(pathOperations)
}, [])
return operationObjects
}
export default getPathsFromSchema
+1 -1
View File
@@ -22,7 +22,7 @@ export default function ProductSubnav() {
url: 'https://github.com/hashicorp/boundary/releases',
},
]}
currentPath={router.pathname}
currentPath={router.asPath}
menuItemsAlign="right"
menuItems={subnavItems}
constrainWidth
+4 -1
View File
@@ -2,6 +2,9 @@ export default [
{
text: 'Docs',
url: '/docs/getting-started',
type: 'inbound',
},
{
text: 'API',
url: '/api-docs',
},
]
+29
View File
@@ -0,0 +1,29 @@
import { useRef, useState, useEffect } from 'react'
function useHover() {
const [value, setValue] = useState(false)
const ref = useRef(null)
const handleMouseOver = () => setValue(true)
const handleMouseOut = () => setValue(false)
useEffect(
() => {
const node = ref.current
if (node) {
node.addEventListener('mouseover', handleMouseOver)
node.addEventListener('mouseout', handleMouseOut)
return () => {
node.removeEventListener('mouseover', handleMouseOver)
node.removeEventListener('mouseout', handleMouseOut)
}
}
},
[ref.current] // Recall only if ref changes
)
return [ref, value]
}
export default useHover
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from 'react'
/**
* Hook that runs a callback on clicks outside the ref
*/
function useOnClickOutside(ref, callbackFn) {
useEffect(() => {
function handleClick(event) {
const isOutside = ref.current && !ref.current.contains(event.target)
if (isOutside) callbackFn(event)
}
// Bind the event listener
document.addEventListener('mousedown', handleClick)
return () => {
// Unbind the event listener on clean up
document.removeEventListener('mousedown', handleClick)
}
}, [ref])
}
export default useOnClickOutside
+8
View File
@@ -0,0 +1,8 @@
const fs = require('fs')
const RefParser = require('@apidevtools/json-schema-ref-parser')
export default async function parseSchema(filePath) {
const content = JSON.parse(fs.readFileSync(filePath))
const schema = await RefParser.dereference(content)
return schema
}
-18
View File
@@ -1,18 +0,0 @@
# This file sets configuration for Netlify
# ref: https://www.netlify.com/docs/netlify-toml-reference/
[build]
publish = "out"
command = "npm run static"
[context.production]
environment = { HASHI_ENV = "production", NODE_ENV = "production"}
[context.deploy-preview]
environment = { HASHI_ENV = "staging" }
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "SAMEORIGIN"
+160
View File
@@ -224,6 +224,16 @@
}
}
},
"@apidevtools/json-schema-ref-parser": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz",
"integrity": "sha512-M3YgsLjI0lZxvrpeGVk9Ap032W6TPQkH6pRAZz81Ac3WUNF79VQooAFnp8umjvVzUmD93NkogxEwbSce7qMsUg==",
"requires": {
"@jsdevtools/ono": "^7.1.3",
"call-me-maybe": "^1.0.1",
"js-yaml": "^3.13.1"
}
},
"@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
@@ -1671,6 +1681,11 @@
"unist-util-visit": "^2.0.2"
}
},
"@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="
},
"@mapbox/rehype-prism": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@mapbox/rehype-prism/-/rehype-prism-0.5.0.tgz",
@@ -3770,6 +3785,15 @@
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
},
"camel-case": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.1.tgz",
"integrity": "sha512-7fa2WcG4fYFkclIvEmxBbTvmibwF2/agfEBc6q3lOpVu0A13ltLsA+Hr/8Hp6kp5f+G7hKi6t8lys6XxP+1K6Q==",
"requires": {
"pascal-case": "^3.1.1",
"tslib": "^1.10.0"
}
},
"camelcase": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz",
@@ -3794,6 +3818,16 @@
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001135.tgz",
"integrity": "sha512-ziNcheTGTHlu9g34EVoHQdIu5g4foc8EsxMGC7Xkokmvw0dqNtX8BS8RgCgFBaAiSp2IdjvBxNdh0ssib28eVQ=="
},
"capital-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.3.tgz",
"integrity": "sha512-OlUSJpUr7SY0uZFOxcwnDOU7/MpHlKTZx2mqnDYQFrDudXLFm0JJ9wr/l4csB+rh2Ug0OPuoSO53PqiZBqno9A==",
"requires": {
"no-case": "^3.0.3",
"tslib": "^1.10.0",
"upper-case-first": "^2.0.1"
}
},
"caw": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/caw/-/caw-2.0.1.tgz",
@@ -3856,6 +3890,25 @@
}
}
},
"change-case": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.1.tgz",
"integrity": "sha512-qRlUWn/hXnX1R1LBDF/RelJLiqNjKjUqlmuBVSEIyye8kq49CXqkZWKmi8XeUAdDXWFOcGLUMZ+aHn3Q5lzUXw==",
"requires": {
"camel-case": "^4.1.1",
"capital-case": "^1.0.3",
"constant-case": "^3.0.3",
"dot-case": "^3.0.3",
"header-case": "^2.0.3",
"no-case": "^3.0.3",
"param-case": "^3.0.3",
"pascal-case": "^3.1.1",
"path-case": "^3.0.3",
"sentence-case": "^3.0.3",
"snake-case": "^3.0.3",
"tslib": "^1.10.0"
}
},
"character-entities": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
@@ -4538,6 +4591,16 @@
"resolved": "https://registry.npmjs.org/console-stream/-/console-stream-0.1.1.tgz",
"integrity": "sha1-oJX+B7IEZZVfL6/Si11yvM2UnUQ="
},
"constant-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.3.tgz",
"integrity": "sha512-FXtsSnnrFYpzDmvwDGQW+l8XK3GV1coLyBN0eBz16ZUzGaZcT2ANVCJmLeuw2GQgxKHQIe9e0w2dzkSfaRlUmA==",
"requires": {
"no-case": "^3.0.3",
"tslib": "^1.10.0",
"upper-case": "^2.0.1"
}
},
"constants-browserify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
@@ -5288,6 +5351,15 @@
"domelementtype": "1"
}
},
"dot-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.3.tgz",
"integrity": "sha512-7hwEmg6RiSQfm/GwPL4AAWXKy3YNNZA3oFv2Pdiey0mwkRCPZ9x6SZbkLcn8Ma5PYeVokzoD4Twv2n7LKp5WeA==",
"requires": {
"no-case": "^3.0.3",
"tslib": "^1.10.0"
}
},
"dotenv": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
@@ -6903,6 +6975,15 @@
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
},
"header-case": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.3.tgz",
"integrity": "sha512-LChe/V32mnUQnTwTxd3aAlNMk8ia9tjCDb/LjYtoMrdAPApxLB+azejUk5ERZIZdIqvinwv6BAUuFXH/tQPdZA==",
"requires": {
"capital-case": "^1.0.3",
"tslib": "^1.10.0"
}
},
"highlight.js": {
"version": "9.18.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.18.3.tgz",
@@ -8352,6 +8433,14 @@
"signal-exit": "^3.0.0"
}
},
"lower-case": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.1.tgz",
"integrity": "sha512-LiWgfDLLb1dwbFQZsSglpRj+1ctGnayXz3Uv0/WO8n558JycT5fg6zkNcnW0G68Nn0aEldTFeEfmjCfmqry/rQ==",
"requires": {
"tslib": "^1.10.0"
}
},
"lowercase-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
@@ -9116,6 +9205,15 @@
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="
},
"no-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.3.tgz",
"integrity": "sha512-ehY/mVQCf9BL0gKfsJBvFJen+1V//U+0HQMPrWct40ixE4jnv0bfvxDbWtAHL9EcaPEOJHVVYKoQn1TlZUB8Tw==",
"requires": {
"lower-case": "^2.0.1",
"tslib": "^1.10.0"
}
},
"node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
@@ -9650,6 +9748,15 @@
"readable-stream": "^2.1.5"
}
},
"param-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.3.tgz",
"integrity": "sha512-VWBVyimc1+QrzappRs7waeN2YmoZFCGXWASRYX1/rGHtXqEcrGEIDm+jqIwFa2fRXNgQEwrxaYuIrX0WcAguTA==",
"requires": {
"dot-case": "^3.0.3",
"tslib": "^1.10.0"
}
},
"parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9714,6 +9821,15 @@
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
},
"pascal-case": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.1.tgz",
"integrity": "sha512-XIeHKqIrsquVTQL2crjq3NfJUxmdLasn3TYOU0VBM+UX2a6ztAWBlJQBePLGY7VHW8+2dRadeIPK5+KImwTxQA==",
"requires": {
"no-case": "^3.0.3",
"tslib": "^1.10.0"
}
},
"pascalcase": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
@@ -9724,6 +9840,15 @@
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="
},
"path-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.3.tgz",
"integrity": "sha512-UMFU6UETFpCNWbIWNczshPrnK/7JAXBP2NYw80ojElbQ2+JYxdqWDBkvvqM93u4u6oLmuJ/tPOf2tM8KtXv4eg==",
"requires": {
"dot-case": "^3.0.3",
"tslib": "^1.10.0"
}
},
"path-dirname": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz",
@@ -11595,6 +11720,16 @@
}
}
},
"sentence-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.3.tgz",
"integrity": "sha512-ZPr4dgTcNkEfcGOMFQyDdJrTU9uQO1nb1cjf+nuzb6FxgMDgKddZOM29qEsB7jvsZSMruLRcL2KfM4ypKpa0LA==",
"requires": {
"no-case": "^3.0.3",
"tslib": "^1.10.0",
"upper-case-first": "^2.0.1"
}
},
"serialize-javascript": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
@@ -11760,6 +11895,15 @@
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.4.5.tgz",
"integrity": "sha512-WpECLAgYaxHoEAJ8Q1Lo8HOs1ngn7LN7QjXgOLbmmfkcWvosyk4ZTXkTzKyhngK640USTZUlgoQJfED1kz5fnQ=="
},
"snake-case": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.3.tgz",
"integrity": "sha512-WM1sIXEO+rsAHBKjGf/6R1HBBcgbncKS08d2Aqec/mrDSpU80SiOU41hO7ny6DToHSyrlwTYzQBIK1FPSx4Y3Q==",
"requires": {
"dot-case": "^3.0.3",
"tslib": "^1.10.0"
}
},
"snapdragon": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
@@ -13687,6 +13831,22 @@
"resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
"integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="
},
"upper-case": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.1.tgz",
"integrity": "sha512-laAsbea9SY5osxrv7S99vH9xAaJKrw5Qpdh4ENRLcaxipjKsiaBwiAsxfa8X5mObKNTQPsupSq0J/VIxsSJe3A==",
"requires": {
"tslib": "^1.10.0"
}
},
"upper-case-first": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.1.tgz",
"integrity": "sha512-105J8XqQ+9RxW3l9gHZtgve5oaiR9TIwvmZAMAIZWRHe00T21cdvewKORTlOJf/zXW6VukuTshM+HXZNWz7N5w==",
"requires": {
"tslib": "^1.10.0"
}
},
"uri-js": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz",
+2
View File
@@ -4,6 +4,7 @@
"version": "1.0.0",
"author": "HashiCorp",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@hashicorp/nextjs-scripts": "13.0.0-canary.1",
"@hashicorp/react-content": "4.0.2",
"@hashicorp/react-docs-page": "5.0.3",
@@ -16,6 +17,7 @@
"@hashicorp/react-subnav": "3.3.1",
"@hashicorp/react-vertical-text-block-list": "2.0.3",
"babel-plugin-import-glob-array": "0.2.0",
"change-case": "^4.1.1",
"fs-exists-sync": "0.1.0",
"gray-matter": "4.0.2",
"js-yaml": "3.14.0",
+33
View File
@@ -0,0 +1,33 @@
import { productName, productSlug } from 'data/metadata'
import path from 'path'
import parseSwagger from '../../lib/swagger-parser'
import OpenApiPage, {
getPathsFromSchema,
getPropsForPage,
} from '../../components/openapi-page'
const targetFile = '../internal/gen/controller.swagger.json'
const pathFromRoot = 'api-docs'
export default function ExamplePage(props) {
return (
<OpenApiPage
{...props}
productName={productName}
productSlug={productSlug}
pathFromRoot={pathFromRoot}
/>
)
}
export async function getStaticPaths() {
const schema = await parseSwagger(path.join(process.cwd(), targetFile))
const paths = getPathsFromSchema(schema)
return { paths, fallback: false }
}
export async function getStaticProps({ params }) {
const schema = await parseSwagger(path.join(process.cwd(), targetFile))
const props = getPropsForPage(schema, params)
return { props }
}
+6
View File
@@ -9,6 +9,12 @@ module.exports = [
permanent: false,
},
// `/<path>/index.html` to /<path>
{
source: '/:splat*/index.html',
destination: '/:splat*',
permanent: true,
},
/////////////////////////////////
// DOMAIN MODEL CONCEPTS
/////////////////////////////////