diff --git a/website/README.md b/website/README.md index a139f1ff3..e15ede984 100644 --- a/website/README.md +++ b/website/README.md @@ -1,8 +1,6 @@ # Boundary Documentation Website -[![Netlify Status](https://img.shields.io/netlify/?style=flat-square)](https://app.netlify.com/sites//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. diff --git a/website/components/openapi-page/index.jsx b/website/components/openapi-page/index.jsx new file mode 100644 index 000000000..3181941ac --- /dev/null +++ b/website/components/openapi-page/index.jsx @@ -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 ( +
+ + {operationCategory ? ( +
+

+ {info.title} +

+

+ {operationCategory.name} +

+
+ {operationCategory.operations.map((op) => { + const isExpanded = + expandedOperations.indexOf(op.operationId) !== -1 + + return ( + + setOperationState(op.operationId, !isCollapsed) + } + /> + ) + })} +
+
+ ) : ( +
+

+ {info.title} +

+

+ Select a service from the sidebar. +

+
+ )} +
+
+ ) +} + +export { getPathsFromSchema, getPropsForPage } +export default OpenApiPage diff --git a/website/components/openapi-page/index.props.js b/website/components/openapi-page/index.props.js new file mode 100644 index 000000000..92c651d83 --- /dev/null +++ b/website/components/openapi-page/index.props.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types' +import OpenAPIPage from '.' + +function OpenAPIPageProps(props) { + return +} + +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 diff --git a/website/components/openapi-page/openapi-page.module.css b/website/components/openapi-page/openapi-page.module.css new file mode 100644 index 000000000..151322017 --- /dev/null +++ b/website/components/openapi-page/openapi-page.module.css @@ -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); + } +} diff --git a/website/components/openapi-page/partials/chevron-down/black.jsx b/website/components/openapi-page/partials/chevron-down/black.jsx new file mode 100644 index 000000000..2c7c0fa0c --- /dev/null +++ b/website/components/openapi-page/partials/chevron-down/black.jsx @@ -0,0 +1,23 @@ +function ChevronDownBlack({ title, ...props }) { + return ( + + {title ? {title} : null} + + + ) +} + +export default ChevronDownBlack diff --git a/website/components/openapi-page/partials/collapsible/collapsible.module.css b/website/components/openapi-page/partials/collapsible/collapsible.module.css new file mode 100644 index 000000000..a2ad211a2 --- /dev/null +++ b/website/components/openapi-page/partials/collapsible/collapsible.module.css @@ -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; + } +} diff --git a/website/components/openapi-page/partials/collapsible/index.js b/website/components/openapi-page/partials/collapsible/index.js new file mode 100644 index 000000000..673d8111d --- /dev/null +++ b/website/components/openapi-page/partials/collapsible/index.js @@ -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 ( +
(this.containerRef = ref)} + data-useanimation={Boolean(!noAnimation)} + > +
+ {children} +
+
+ ) + } +} diff --git a/website/components/openapi-page/partials/operation-object/index.js b/website/components/openapi-page/partials/operation-object/index.js new file mode 100644 index 000000000..2c6a87373 --- /dev/null +++ b/website/components/openapi-page/partials/operation-object/index.js @@ -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 ( +
+
setIsCollapsed(!isCollapsed)} + > +
+
+ {title} +
+
+ + {type.toUpperCase()}{' '} + + {path} +
+
+
+ + {isCollapsed ? 'Expand' : 'Collapse'} + + + + +
+
+ +
+

{summary}

+ +

Request

+ {pathParams.length > 0 ? ( + + ) : null} + {queryParams.length > 0 ? ( + + ) : null} + {bodyProps.length > 0 ? ( + + ) : null} +
+ } + columnTwo={ +
+

Response

+ {!!successResponse ? ( +
+

+ Successful Response +

+ +
+ ) : ( +

No response has been defined.

+ )} +
+ } + /> +
+ + + ) +} + +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 ( +
+
{columnOne}
+
+
{columnTwo}
+
+ ) +} + +function Parameters({ title, params }) { + return ( +
+

+ {title} +

+ {params.map((parameter, idx) => { + return ( + + ) + })} +
+ ) +} + +export default OperationObject diff --git a/website/components/openapi-page/partials/operation-object/index.props.js b/website/components/openapi-page/partials/operation-object/index.props.js new file mode 100644 index 000000000..a414821d5 --- /dev/null +++ b/website/components/openapi-page/partials/operation-object/index.props.js @@ -0,0 +1,61 @@ +import React from 'react' +import PropTypes from 'prop-types' +import OperationObject from '.' + +function OperationObjectProps(props) { + return +} + +/** 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 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 component. */ + 200: PropTypes.object, + }), + }), +} + +export default OperationObjectProps diff --git a/website/components/openapi-page/partials/operation-object/operation-object.module.css b/website/components/openapi-page/partials/operation-object/operation-object.module.css new file mode 100644 index 000000000..655bad5f4 --- /dev/null +++ b/website/components/openapi-page/partials/operation-object/operation-object.module.css @@ -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; + } +} diff --git a/website/components/openapi-page/partials/property-object/index.js b/website/components/openapi-page/partials/property-object/index.js new file mode 100644 index 000000000..63e672a20 --- /dev/null +++ b/website/components/openapi-page/partials/property-object/index.js @@ -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 ( + + ) + const hasProperties = data.type === 'object' && Boolean(data.properties) + + const typeArraySuffix = + arrayDepth > 0 ? arrayFrom(arrayDepth, '[]').join('') : '' + const typeString = `${data.type}${typeArraySuffix}` + return ( +
+ {name}{' '} + {typeString}{' '} + {data.required ? ( + + Required + + ) : null} + {data.title && ( +

{data.title}

+ )} + {data.description && ( +

+ {data.description} +

+ )} + {hasProperties && ( +
+ + +
+ {Object.keys(data.properties).map((propertyKey, idx) => { + return ( + + ) + })} +
+
+
+ )} +
+ ) +} + +function arrayFrom(length, value = null) { + let array = [] + for (var i = 0; i < length; i++) { + array.push(value) + } + return array +} + +export default PropertyObject diff --git a/website/components/openapi-page/partials/property-object/property-object.module.css b/website/components/openapi-page/partials/property-object/property-object.module.css new file mode 100644 index 000000000..24ddd788a --- /dev/null +++ b/website/components/openapi-page/partials/property-object/property-object.module.css @@ -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); + } +} diff --git a/website/components/openapi-page/partials/response-object/index.js b/website/components/openapi-page/partials/response-object/index.js new file mode 100644 index 000000000..4d39fe833 --- /dev/null +++ b/website/components/openapi-page/partials/response-object/index.js @@ -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
No content.
+ } + return ( +
+ {Object.keys(data.schema.properties).map((propertyKey, idx) => { + return ( + + ) + })} +
+ ) +} + +export default ResponseObject diff --git a/website/components/openapi-page/partials/response-object/index.props.js b/website/components/openapi-page/partials/response-object/index.props.js new file mode 100644 index 000000000..8ed469813 --- /dev/null +++ b/website/components/openapi-page/partials/response-object/index.props.js @@ -0,0 +1,16 @@ +import PropTypes from 'prop-types' +import ResponseObject from '.' + +function ResponseObjectProps(props) { + return +} + +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 diff --git a/website/components/openapi-page/utils/routing-utils.js b/website/components/openapi-page/utils/routing-utils.js new file mode 100644 index 000000000..93bff6ddf --- /dev/null +++ b/website/components/openapi-page/utils/routing-utils.js @@ -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 +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 diff --git a/website/components/subnav/index.jsx b/website/components/subnav/index.jsx index 4d046bd09..6d6c14cf4 100644 --- a/website/components/subnav/index.jsx +++ b/website/components/subnav/index.jsx @@ -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 diff --git a/website/data/navigation.js b/website/data/navigation.js index a48c439d0..1eb1a6e8d 100644 --- a/website/data/navigation.js +++ b/website/data/navigation.js @@ -2,6 +2,9 @@ export default [ { text: 'Docs', url: '/docs/getting-started', - type: 'inbound', + }, + { + text: 'API', + url: '/api-docs', }, ] diff --git a/website/lib/hooks/use-hover.js b/website/lib/hooks/use-hover.js new file mode 100644 index 000000000..f7d8bc052 --- /dev/null +++ b/website/lib/hooks/use-hover.js @@ -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 diff --git a/website/lib/hooks/use-on-click-outside.js b/website/lib/hooks/use-on-click-outside.js new file mode 100644 index 000000000..e4b56e990 --- /dev/null +++ b/website/lib/hooks/use-on-click-outside.js @@ -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 diff --git a/website/lib/swagger-parser.js b/website/lib/swagger-parser.js new file mode 100644 index 000000000..5cd153452 --- /dev/null +++ b/website/lib/swagger-parser.js @@ -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 +} diff --git a/website/netlify.toml b/website/netlify.toml deleted file mode 100644 index 52eb75214..000000000 --- a/website/netlify.toml +++ /dev/null @@ -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" diff --git a/website/package-lock.json b/website/package-lock.json index 9a12cca09..679e78157 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -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", diff --git a/website/package.json b/website/package.json index 5c75e8e6e..cb673aef3 100644 --- a/website/package.json +++ b/website/package.json @@ -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", diff --git a/website/pages/api-docs/[[...page]].jsx b/website/pages/api-docs/[[...page]].jsx new file mode 100644 index 000000000..c60543eba --- /dev/null +++ b/website/pages/api-docs/[[...page]].jsx @@ -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 ( + + ) +} + +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 } +} diff --git a/website/redirects.js b/website/redirects.js index 375fdc6c1..23402d642 100644 --- a/website/redirects.js +++ b/website/redirects.js @@ -9,6 +9,12 @@ module.exports = [ permanent: false, }, + // `//index.html` to / + { + source: '/:splat*/index.html', + destination: '/:splat*', + permanent: true, + }, ///////////////////////////////// // DOMAIN MODEL CONCEPTS /////////////////////////////////