Skip to content

Commit

Permalink
feat: stub in OpenApiDocsView preview tool (#2156)
Browse files Browse the repository at this point in the history
* feat: add api route to generate props

* feat: add necessary basic input components

* fix: make statusIndicatorConfig optional

* feat: function to call API route for static props

* feat: rough in input components, set up the view

* feat: add page file, don't render in prod

* chore: tidy up breadcrumb links

* chore: fix up inaccurate basePath

* fix: IS_PRODUCTION logic

* chore: use HASHI_ENV to follow convention

* fix: fix token for visible text in light mode

* feat: support helperText on inputs

* feat: add helperText to inputs

* fix: better error handling for invalid schemas

* fix: bad assumption around response definition

* fix: support arbitrary content type keys

* chore: minor form improvements

* style: rename props state for clarity
  • Loading branch information
zchsh authored Sep 12, 2023
1 parent f0e3ec3 commit 89b4271
Show file tree
Hide file tree
Showing 13 changed files with 561 additions and 3 deletions.
1 change: 1 addition & 0 deletions src/lib/get-breadcrumb-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const KNOWN_URL_TITLE: Record<string, string> = {
const KNOWN_URL_SEGMENT_TITLE: Record<string, string> = {
'api-docs': 'API',
'vault-secrets': 'Vault Secrets',
'open-api-docs-preview': 'OpenAPI Docs Preview Tool',
}

/**
Expand Down
116 changes: 116 additions & 0 deletions src/pages/api/get-open-api-docs-view-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { getStaticProps } from 'views/open-api-docs-view/server'
// Types
import type { NextApiRequest, NextApiResponse } from 'next'
import type { OpenAPIV3 } from 'openapi-types'
import type { ProductSlug } from 'types/products'

/**
* Boilerplate page configuration, we could in theory expose this so visitors
* to the preview tool could manipulate it, but we intentionally just
* hard-code here to keep the focus of the preview tool on OpenAPI spec
* contents.
*/
const GENERIC_PAGE_CONFIG = {
// basePath same no matter what, preview tool is on static route
basePath: '/open-api-docs-preview',
// No versioning in the preview tool, focus on one spec file at a time
context: { params: { page: [] } },
// Product slug, using HCP to just show a generic HashiCorp logo,
// so that the preview tool's focus can remain on the spec file contents
productSlug: 'hcp' as ProductSlug,
// Generic resource items, we can set more specific ones closer to launch
navResourceItems: [
{
title: 'Tutorial Library',
href: '/tutorials/library',
},
{
title: 'Certifications',
href: '/certifications',
},
{
title: 'Community',
href: 'https://discuss.hashicorp.com/',
},
{
title: 'Support',
href: 'https://www.hashicorp.com/customer-success',
},
],
}

/**
* We expected posted data to be an OpenAPI spec in JSON format.
* We also allow an optional schema `info.description`, which normally would be
* included in the spec content, so that authors can more easily develop
* and preview their `info.description` content using our preview tool.
*/
type ExpectedBody = {
openApiJsonString: string
openApiDescription: string
}

export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Reject non-POST requests, only POST is allowed
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST'])
res.status(405).json({ error: 'Method not allowed' })
}

/**
* Build the static props from the POST'ed page configuration data,
* which includes the full OpenAPI spec as a string.
*/
const { openApiDescription, openApiJsonString } = JSON.parse(
req.body
) as ExpectedBody

/**
* Construct some preview data just to match the expected `getStaticProps`
* signature. The `versionId` and `releaseStage` don't really matter here.
*/
const versionData = [
{
versionId: 'preview',
releaseStage: 'preview',
sourceFile: openApiJsonString,
},
]

/**
* Build static props for the page
*/
try {
const staticProps = await getStaticProps({
// Pass the bulk of the page config
...GENERIC_PAGE_CONFIG,
// Pass the constructed version data
versionData,
/**
* Massage the schema data a little bit, replacing
* "HashiCorp Cloud Platform" in the title with "HCP".
*/
massageSchemaForClient: (schemaData: OpenAPIV3.Document) => {
// Replace the schema description with the POST'ed description, if present
if (openApiDescription) {
schemaData.info.description = openApiDescription
}
// Replace "HashiCorp Cloud Platform" with "HCP" in the title
const massagedTitle = schemaData.info.title.replace(
'HashiCorp Cloud Platform',
'HCP'
)
// Return the schema data with the revised title
const massagedInfo = { ...schemaData.info, title: massagedTitle }
return { ...schemaData, info: massagedInfo }
},
})
// Return the static props as JSON, these can be passed to OpenApiDocsView
res.status(200).json(staticProps)
} catch (error) {
res.status(200).json({ error: error.toString() })
}
}
26 changes: 26 additions & 0 deletions src/pages/open-api-docs-preview/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/

import { GetStaticPropsResult } from 'next'
import OpenApiDocsPreviewView from 'views/open-api-docs-preview'

const IS_PRODUCTION = process.env.HASHI_ENV === 'production'

/**
* We don't actually need static props for this page,
* we use `getStaticProps` here to prevent the page from being rendered
* in production.
*/
export async function getStaticProps(): Promise<
GetStaticPropsResult<Record<string, never>>
> {
/**
* In production, return a 404 not found for this page.
* In other environments (local, preview, and staging), show the page.
*/
return IS_PRODUCTION ? { notFound: true } : { props: {} }
}

export default OpenApiDocsPreviewView
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.root {
display: flex;
flex-direction: column;
gap: 8px;
}

.helperText {
color: var(--token-form-helper-text-color);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useId, type ChangeEvent } from 'react'
import s from './file-string-input.module.css'

/**
* Render a very basic file input.
*
* This is a temporary solution until we have a React version of FileInput
* set up, which we'd likely do in the `web` monorepo.
*
* For now, this felt sufficient for an internal preview tool for OpenAPI specs.
*/
export function FileStringInput({
label,
helperText,
accept,
setValue,
}: {
label: string
helperText?: string
accept: string
setValue: (fileString: string) => void
}) {
const id = useId()

function handleFileInputChange(e: ChangeEvent<HTMLInputElement>) {
const fileReader = new FileReader()
fileReader.readAsText(e.target.files[0], 'UTF-8')
fileReader.onload = (e: ProgressEvent<FileReader>) =>
setValue(e.target.result.toString())
}

return (
<div className={s.root}>
<div>
<label htmlFor={id}>{label}</label>
{helperText ? <div className={s.helperText}>{helperText}</div> : null}
</div>
<input
id={id}
type="file"
accept={accept}
onChange={handleFileInputChange}
/>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Third-party
import { useEffect, useState } from 'react'
import classNames from 'classnames'
// Components
import Button from 'components/button'
import InlineAlert from 'components/inline-alert'
// Inputs
import { FileStringInput } from '../file-string-input'
import { TextareaInput } from '../textarea-input'
// Utils
import { fetchOpenApiStaticProps } from './utils/fetch-open-api-static-props'
// Types
import type { OpenApiDocsViewProps } from 'views/open-api-docs-view/types'
// Styles
import s from './open-api-preview-inputs.module.css'

interface InputValues {
openApiJsonString: string
openApiDescription: string
}

/**
* Render a fixed panel container with inputs that allow control over the
* static props for `OpenApiDocsView`.
*/
export function OpenApiPreviewInputs({
setStaticProps,
}: {
setStaticProps: (v: OpenApiDocsViewProps) => void
}) {
const [error, setError] = useState<{
title: string
description: string
error: string
}>()
const [isLoading, setIsLoading] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(false)
const [inputValues, setInputValues] = useState<InputValues>({
openApiJsonString: '',
openApiDescription: '',
})

/**
* Helper to set a specific input data value.
*/
function setInputValue(key: keyof InputValues, value: string) {
setInputValues((p: InputValues) => ({ ...p, [key]: value }))
}

/**
* Whenever an input value changes, reset the error
*/
useEffect(() => {
if (error) {
setError(undefined)
}
// Note: intentionally not using exhaustive deps, reset based on input only
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputValues])

/**
* Pre-fill the description markdown when a new OpenAPI JSON string is set.
*/
useEffect(() => {
// Avoid overwriting an in-progress description.
if (inputValues.openApiDescription !== '') {
return
}
try {
// try to parse the input JSON, and set the description accordingly
const parsed = JSON.parse(inputValues.openApiJsonString)
const parsedValue = parsed?.info?.description
if (parsedValue && inputValues.openApiDescription !== parsedValue) {
setInputValue('openApiDescription', parsedValue)
}
} catch (e) {
// do nothing if parsing fails
}
}, [inputValues])

/**
* Fetch static props for the page and update state when
* the provided `inputValues` is submitted via a button activation.
*/
async function updateStaticProps() {
setIsLoading(true)
const [err, result] = await fetchOpenApiStaticProps(inputValues)
err ? setError(err) : setStaticProps(result)
setIsLoading(false)
}

/**
* Render the input panel
*/
return (
<div className={classNames(s.root, { [s.isCollapsed]: isCollapsed })}>
<div className={s.scrollableContent}>
<div className={s.inputs}>
<FileStringInput
label="OpenAPI File"
helperText='Upload your OpenAPI specification file, in ".json" format.'
accept=".json"
setValue={(v: string) => setInputValue('openApiJsonString', v)}
/>
<Button
className={s.generateButton}
text={isLoading ? 'Loading...' : 'Generate preview'}
size="large"
onClick={() => updateStaticProps()}
/>
{error ? (
<InlineAlert
color="critical"
title={error.title}
description={
<>
<div>{error.description}</div>
<pre style={{ whiteSpace: 'pre-wrap' }}>
<code>{error.error}</code>
</pre>
</>
}
/>
) : null}
<TextareaInput
label="Schema source"
helperText="Test out edits to the uploaded OpenAPI specification file."
value={inputValues.openApiJsonString}
setValue={(v: string) => setInputValue('openApiJsonString', v)}
/>
<TextareaInput
label="Description Markdown"
helperText='Enter markdown here to override the "schema.info.description" field of your schema.'
value={inputValues.openApiDescription}
setValue={(v: string) => setInputValue('openApiDescription', v)}
/>
</div>
</div>
<div className={s.collapseButtonLayout}>
<button
className={s.collapseButton}
onClick={() => setIsCollapsed((p: boolean) => !p)}
>
{isCollapsed ? 'Show' : 'Hide'} preview inputs
</button>
</div>
</div>
)
}
Loading

1 comment on commit 89b4271

@vercel
Copy link

@vercel vercel bot commented on 89b4271 Sep 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.