From 449764928e9fb726dc5453bd7858d062915b6432 Mon Sep 17 00:00:00 2001 From: Zach Shilton <4624598+zchsh@users.noreply.github.com> Date: Mon, 25 Sep 2023 15:17:27 -0400 Subject: [PATCH] feat(openapi): implement tag-based grouping as default (#2172) * feat(openapi): implement tag-based grouping * fix: clean up service in operationId * fix: handle non-service operation ids * chore: clean up stray placeholder data * feat: implement groupOperationsByPath config --- src/pages/api/get-open-api-docs-view-props.ts | 8 +++--- .../api-docs/vault-secrets/[[...page]].tsx | 2 ++ .../open-api-preview-inputs/index.tsx | 16 +++++++++++- .../utils/fetch-open-api-static-props.ts | 1 + src/views/open-api-docs-view/server.ts | 20 +++------------ src/views/open-api-docs-view/types.ts | 23 +++++++++-------- .../utils/get-operation-props.ts | 17 +++++++------ .../utils/group-operations.ts | 25 ++++++++++++++----- 8 files changed, 68 insertions(+), 44 deletions(-) diff --git a/src/pages/api/get-open-api-docs-view-props.ts b/src/pages/api/get-open-api-docs-view-props.ts index ca2fc779a3..78a83f7877 100644 --- a/src/pages/api/get-open-api-docs-view-props.ts +++ b/src/pages/api/get-open-api-docs-view-props.ts @@ -53,6 +53,7 @@ const GENERIC_PAGE_CONFIG = { type ExpectedBody = { openApiJsonString: string openApiDescription: string + groupOperationsByPath: boolean } export default async function handler( @@ -69,9 +70,8 @@ export default async function handler( * 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 + const { openApiDescription, openApiJsonString, groupOperationsByPath } = + JSON.parse(req.body) as ExpectedBody /** * Construct some preview data just to match the expected `getStaticProps` @@ -94,6 +94,8 @@ export default async function handler( ...GENERIC_PAGE_CONFIG, // Pass the constructed version data versionData, + // Pass options + groupOperationsByPath, /** * Massage the schema data a little bit, replacing * "HashiCorp Cloud Platform" in the title with "HCP". diff --git a/src/pages/hcp/api-docs/vault-secrets/[[...page]].tsx b/src/pages/hcp/api-docs/vault-secrets/[[...page]].tsx index 2ef40b8ce9..8319da2602 100644 --- a/src/pages/hcp/api-docs/vault-secrets/[[...page]].tsx +++ b/src/pages/hcp/api-docs/vault-secrets/[[...page]].tsx @@ -33,6 +33,7 @@ const PAGE_CONFIG: OpenApiDocsPageConfig = { path: 'specs/cloud-vault-secrets', ref: 'main', }, + groupOperationsByPath: true, statusIndicatorConfig: { pageUrl: 'https://status.hashicorp.com', endpointUrl: @@ -102,6 +103,7 @@ export const getStaticProps: GetStaticProps< serviceProductSlug: PAGE_CONFIG.serviceProductSlug, statusIndicatorConfig: PAGE_CONFIG.statusIndicatorConfig, navResourceItems: PAGE_CONFIG.navResourceItems, + groupOperationsByPath: PAGE_CONFIG.groupOperationsByPath, massageSchemaForClient: PAGE_CONFIG.massageSchemaForClient, // Pass params to getStaticProps, this is used for versioning context: { params }, diff --git a/src/views/open-api-docs-preview/components/open-api-preview-inputs/index.tsx b/src/views/open-api-docs-preview/components/open-api-preview-inputs/index.tsx index daedbb6149..ce374bd017 100644 --- a/src/views/open-api-docs-preview/components/open-api-preview-inputs/index.tsx +++ b/src/views/open-api-docs-preview/components/open-api-preview-inputs/index.tsx @@ -9,6 +9,7 @@ import classNames from 'classnames' // Components import Button from 'components/button' import InlineAlert from 'components/inline-alert' +import { CheckboxField } from 'components/form/field-controls' // Inputs import { FileStringInput } from '../file-string-input' import { TextareaInput } from '../textarea-input' @@ -22,6 +23,7 @@ import s from './open-api-preview-inputs.module.css' interface InputValues { openApiJsonString: string openApiDescription: string + groupOperationsByPath: boolean } /** @@ -43,12 +45,13 @@ export function OpenApiPreviewInputs({ const [inputValues, setInputValues] = useState({ openApiJsonString: '', openApiDescription: '', + groupOperationsByPath: false, }) /** * Helper to set a specific input data value. */ - function setInputValue(key: keyof InputValues, value: string) { + function setInputValue(key: keyof InputValues, value: unknown) { setInputValues((p: InputValues) => ({ ...p, [key]: value })) } @@ -127,6 +130,17 @@ export function OpenApiPreviewInputs({ } /> ) : null} + + setInputValue( + 'groupOperationsByPath', + !inputValues.groupOperationsByPath + ) + } + /> { try { const result = await fetch(API_ROUTE, { diff --git a/src/views/open-api-docs-view/server.ts b/src/views/open-api-docs-view/server.ts index 67d15e1e66..2ed96f3a7a 100644 --- a/src/views/open-api-docs-view/server.ts +++ b/src/views/open-api-docs-view/server.ts @@ -32,6 +32,7 @@ import type { OpenApiDocsVersionData, StatusIndicatorConfig, OpenApiNavItem, + OpenApiDocsPageConfig, } from './types' /** @@ -72,20 +73,12 @@ export async function getStaticProps({ basePath, statusIndicatorConfig, topOfPageId = 'overview', + groupOperationsByPath = false, massageSchemaForClient = (s: OpenAPIV3.Document) => s, navResourceItems = [], -}: { +}: Omit & { context: GetStaticPropsContext - productSlug: ProductSlug - serviceProductSlug?: ProductSlug versionData: OpenApiDocsVersionData[] - basePath: string - statusIndicatorConfig?: StatusIndicatorConfig - topOfPageId?: string - massageSchemaForClient?: ( - schemaData: OpenAPIV3.Document - ) => OpenAPIV3.Document - navResourceItems: OpenApiNavItem[] }): Promise> { // Get the product data const productData = cachedGetProductData(productSlug) @@ -120,7 +113,7 @@ export async function getStaticProps({ const rawSchemaData = await parseAndValidateOpenApiSchema(schemaFileString) const schemaData = massageSchemaForClient(rawSchemaData) const operationProps = await getOperationProps(schemaData) - const operationGroups = groupOperations(operationProps) + const operationGroups = groupOperations(operationProps, groupOperationsByPath) const navItems = getNavItems({ operationGroups, topOfPageId, @@ -158,11 +151,6 @@ export async function getStaticProps({ }, releaseStage: targetVersion.releaseStage, descriptionMdx, - _placeholder: { - productSlug, - targetVersion, - schemaData, - }, operationGroups: stripUndefinedProperties(operationGroups), navItems, navResourceItems, diff --git a/src/views/open-api-docs-view/types.ts b/src/views/open-api-docs-view/types.ts index 2457b74e35..6fc44c09e3 100644 --- a/src/views/open-api-docs-view/types.ts +++ b/src/views/open-api-docs-view/types.ts @@ -21,6 +21,7 @@ import type { PropertyDetailsSectionProps } from './components/operation-details */ export interface OperationProps { operationId: string + tags: string[] slug: string type: string path: { @@ -35,11 +36,6 @@ export interface OperationProps { * word breaks to allow long URLs to wrap to multiple lines. */ urlPathForCodeBlock: string - /** - * Some temporary data to mess around with during prototyping. - * TODO: remove this for the production implementation. - */ - _placeholder: $TSFixMe } /** @@ -167,11 +163,6 @@ export interface OpenApiDocsViewProps { */ statusIndicatorConfig: StatusIndicatorConfig - /** - * Some temporary data we'll remove for the production implementation. - */ - _placeholder: $TSFixMe - /** * Product slug to use for the theming of the service itself. * For example, many product-themed services exist within the broader @@ -224,4 +215,16 @@ export interface OpenApiDocsPageConfig { * but before we translate the schema into page props. */ massageSchemaForClient?: (schema: OpenAPIV3.Document) => OpenAPIV3.Document + /** + * The top-of-page heading optionally have an id other than "overview". + * This heading ID is used to jump to the top of the page + */ + topOfPageId?: string + /** + * Optionally group operations by their URL path. By default, operations are + * grouped by their first `tag`, which is expected to correspond to a service. + * In some cases, a spec may only have a single service, rendering this + * tag-based grouping less useful. + */ + groupOperationsByPath?: boolean } diff --git a/src/views/open-api-docs-view/utils/get-operation-props.ts b/src/views/open-api-docs-view/utils/get-operation-props.ts index 2bdc475746..2cb8d8d24b 100644 --- a/src/views/open-api-docs-view/utils/get-operation-props.ts +++ b/src/views/open-api-docs-view/utils/get-operation-props.ts @@ -78,15 +78,20 @@ export async function getOperationProps( /** * Build a fallback summary for the operation, which is just * the operationId with some formatting for better line-breaks. + * We also apply logic to remove the first part of the operationId, + * which by convention will be formatted `ServiceName_OperationName`. * * TODO: update to actually use `summary`, for now we only use * `operationId` as `summary` values are not yet reliably present * & accurate. Task: * https://app.asana.com/0/1204678746647847/1205338583217309/f */ - const summary = addWordBreaks( - splitOnCapitalLetters(operation.operationId) - ) + const operationIdParts = operation.operationId.split('_') + const hasServicePart = operationIdParts.length > 1 + const idForSummary = hasServicePart + ? operationIdParts.slice(1).join('_') + : operation.operationId + const summary = addWordBreaks(splitOnCapitalLetters(idForSummary)) /** * Format and push the operation props @@ -95,6 +100,7 @@ export async function getOperationProps( operationId: operation.operationId, slug: operationSlug, type, + tags: operation.tags ?? [], path: { full: path, truncated: addWordBreaksToUrl(truncateHcpOperationPath(path)), @@ -103,11 +109,6 @@ export async function getOperationProps( requestData, responseData, urlPathForCodeBlock: getUrlPathCodeHtml(serverUrl + path), - _placeholder: { - __type: type, - __path: path, - ...operation, - }, }) } } diff --git a/src/views/open-api-docs-view/utils/group-operations.ts b/src/views/open-api-docs-view/utils/group-operations.ts index bd2ad65869..fbf80b76f2 100644 --- a/src/views/open-api-docs-view/utils/group-operations.ts +++ b/src/views/open-api-docs-view/utils/group-operations.ts @@ -4,7 +4,6 @@ */ import { OperationProps, OperationGroup } from '../types' -import { truncateHcpOperationPath } from '../utils' import { addWordBreaksToUrl } from './add-word-breaks-to-url' /** @@ -51,17 +50,31 @@ import { addWordBreaksToUrl } from './add-word-breaks-to-url' * } */ export function groupOperations( - operationObjects: OperationProps[] + operationObjects: OperationProps[], + groupOperationsByPath: boolean ): OperationGroup[] { + // Group operations, either by tags where specified, or automatically by paths + // or by their paths otherwise. const operationGroupsMap = operationObjects.reduce( ( acc: Record, o: OperationProps ) => { - // Truncate the common HCP-related prefix from the path, if applicable - const truncatedPath = truncateHcpOperationPath(o._placeholder.__path) - // Grab the first two path segments, to use as a group slug - const groupSlug = truncatedPath.split('/').slice(0, 3).join('/') + /** + * Determine the grouping slug for this operation. + * + * If path-based grouping has been specified, we ignore tags and group + * based on the operation URL paths (truncated to remove common parts). + * + * If tag-based grouping is used, note that we may need to fall back + * to an "Other" tag for potentially untagged operations. + */ + let groupSlug: string + if (groupOperationsByPath) { + groupSlug = o.path.truncated.split('/').slice(0, 3).join('/') + } else { + groupSlug = (o.tags.length && o.tags[0]) ?? 'Other' + } if (!acc[groupSlug]) { acc[groupSlug] = { heading: addWordBreaksToUrl(groupSlug),