diff --git a/demo/src/App.tsx b/demo/src/App.tsx index bc003528ca..6a1aa94a13 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -16,6 +16,8 @@ export function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const isStacked = searchParams.get('layout') === 'stacked'; + const [globalState, setGlobalState] = useState({ apiDescriptionUrl: searchParams.get('spec') || DEFAULT_API_URL, setDescriptionUrl: _value => { @@ -23,7 +25,7 @@ export function App() { let nextUrl = '/'; if (value && value !== DEFAULT_API_URL) { - nextUrl = `?spec=${value}`; + nextUrl = `?spec=${value}${isStacked ? '&layout=stacked' : undefined}`; } window.history.pushState(undefined, '', nextUrl); @@ -35,6 +37,7 @@ export function App() { }); }, 0); }, + layout: isStacked ? 'stacked' : undefined, }); return ( diff --git a/demo/src/components/ElementsAPI.tsx b/demo/src/components/ElementsAPI.tsx index be37ec2330..f68f357470 100644 --- a/demo/src/components/ElementsAPI.tsx +++ b/demo/src/components/ElementsAPI.tsx @@ -7,7 +7,7 @@ import React, { useContext } from 'react'; import { GlobalContext } from '../context'; export const ElementsAPI: React.FC = () => { - const { apiDescriptionUrl } = useContext(GlobalContext); + const { apiDescriptionUrl, layout } = useContext(GlobalContext); const specUrlWithProxy = apiDescriptionUrl && window.location.origin === 'https://elements-demo.stoplight.io' @@ -15,8 +15,8 @@ export const ElementsAPI: React.FC = () => { : apiDescriptionUrl; return ( - - + + ); }; diff --git a/demo/src/context.ts b/demo/src/context.ts index 729939813a..226cddf375 100644 --- a/demo/src/context.ts +++ b/demo/src/context.ts @@ -5,6 +5,7 @@ import { DEFAULT_API_URL } from './constants'; export type GlobalContext = { apiDescriptionUrl: string; setDescriptionUrl: (value: string) => void; + layout?: 'stacked' | 'sidebar'; }; export const defaultGlobalContext: GlobalContext = { diff --git a/packages/elements-core/package.json b/packages/elements-core/package.json index fd8ebef2e1..a35e4e3dca 100644 --- a/packages/elements-core/package.json +++ b/packages/elements-core/package.json @@ -57,7 +57,7 @@ ] }, "dependencies": { - "@stoplight/http-spec": "^6.0.0", + "@stoplight/http-spec": "^7.0.1", "@stoplight/json": "^3.18.1", "@stoplight/json-schema-ref-parser": "^9.0.5", "@stoplight/json-schema-sampler": "0.2.3", @@ -69,7 +69,7 @@ "@stoplight/mosaic-code-viewer": "^1.46.1", "@stoplight/path": "^1.3.2", "@stoplight/react-error-boundary": "^3.0.0", - "@stoplight/types": "^14.0.0", + "@stoplight/types": "^14.1.1", "@stoplight/yaml": "^4.2.3", "classnames": "^2.2.6", "httpsnippet-lite": "^3.0.5", diff --git a/packages/elements-core/src/components/Docs/Docs.tsx b/packages/elements-core/src/components/Docs/Docs.tsx index d422209f65..2fe59f9a24 100644 --- a/packages/elements-core/src/components/Docs/Docs.tsx +++ b/packages/elements-core/src/components/Docs/Docs.tsx @@ -183,6 +183,7 @@ export const ParsedDocs = ({ node, nodeUnsupported, ...commonProps }: ParsedDocs case 'article': return
; case 'http_operation': + case 'http_webhook': return ; case 'http_service': return ; diff --git a/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.tsx b/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.tsx index 0bc5906b46..226c9d2dde 100644 --- a/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.tsx +++ b/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.tsx @@ -1,6 +1,6 @@ import { Box, Flex, Heading, HStack, NodeAnnotation, useThemeIsDark, VStack } from '@stoplight/mosaic'; import { withErrorBoundary } from '@stoplight/react-error-boundary'; -import { IHttpOperation } from '@stoplight/types'; +import { IHttpEndpointOperation, IHttpOperation } from '@stoplight/types'; import cn from 'classnames'; import { useAtomValue } from 'jotai/utils'; import * as React from 'react'; @@ -10,6 +10,7 @@ import { MockingContext } from '../../../containers/MockingProvider'; import { useResolvedObject } from '../../../context/InlineRefResolver'; import { useOptionsCtx } from '../../../context/Options'; import { useIsCompact } from '../../../hooks/useIsCompact'; +import { isHttpOperation, isHttpWebhookOperation } from '../../../utils/guards'; import { MarkdownViewer } from '../../MarkdownViewer'; import { chosenServerAtom, TryItWithRequestSamples } from '../../TryIt'; import { DocsComponentProps } from '..'; @@ -19,12 +20,12 @@ import { Callbacks } from './Callbacks'; import { Request } from './Request'; import { Responses } from './Responses'; -export type HttpOperationProps = DocsComponentProps; +export type HttpOperationProps = DocsComponentProps; const HttpOperationComponent = React.memo( ({ className, data: unresolvedData, layoutOptions, tryItCredentialsPolicy, tryItCorsProxy }) => { const { nodeHasChanged } = useOptionsCtx(); - const data = useResolvedObject(unresolvedData) as IHttpOperation; + const data = useResolvedObject(unresolvedData) as IHttpEndpointOperation; const { ref: layoutRef, isCompact } = useIsCompact(layoutOptions); const mocking = React.useContext(MockingContext); @@ -38,11 +39,20 @@ const HttpOperationComponent = React.memo( const prettyName = (data.summary || data.iid || '').trim(); const hasBadges = isDeprecated || isInternal; + let path: string; + if (isHttpOperation(data)) { + path = data.path; + } else if (isHttpWebhookOperation(data)) { + path = data.name; + } else { + throw new RangeError('unsupported node type'); + } + const header = ( void; } diff --git a/packages/elements-core/src/components/ResponseExamples/ResponseExamples.tsx b/packages/elements-core/src/components/ResponseExamples/ResponseExamples.tsx index 2bdd032a80..d406bfde66 100644 --- a/packages/elements-core/src/components/ResponseExamples/ResponseExamples.tsx +++ b/packages/elements-core/src/components/ResponseExamples/ResponseExamples.tsx @@ -1,13 +1,13 @@ import { CopyButton, Panel, Select, Text } from '@stoplight/mosaic'; import { CodeViewer } from '@stoplight/mosaic-code-viewer'; -import { IHttpOperation, IMediaTypeContent } from '@stoplight/types'; +import { IHttpEndpointOperation, IMediaTypeContent } from '@stoplight/types'; import * as React from 'react'; import { exceedsSize, useGenerateExampleFromMediaTypeContent } from '../../utils/exampleGeneration/exampleGeneration'; import { LoadMore } from '../LoadMore'; export interface ResponseExamplesProps { - httpOperation: IHttpOperation; + httpOperation: IHttpEndpointOperation; responseStatusCode?: string; responseMediaType?: string; } diff --git a/packages/elements-core/src/components/MosaicTableOfContents/TableOfContents.spec.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx similarity index 100% rename from packages/elements-core/src/components/MosaicTableOfContents/TableOfContents.spec.tsx rename to packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx diff --git a/packages/elements-core/src/components/MosaicTableOfContents/TableOfContents.stories.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.stories.tsx similarity index 98% rename from packages/elements-core/src/components/MosaicTableOfContents/TableOfContents.stories.tsx rename to packages/elements-core/src/components/TableOfContents/TableOfContents.stories.tsx index ccdd801a22..2cce2179b4 100644 --- a/packages/elements-core/src/components/MosaicTableOfContents/TableOfContents.stories.tsx +++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.stories.tsx @@ -6,7 +6,7 @@ import { TableOfContents } from './TableOfContents'; import { TableOfContentsProps } from './types'; export default { - title: 'Internal/MosaicTableOfContents', + title: 'Internal/TableOfContents', component: TableOfContents, argTypes: { tree: { table: { category: 'Input' } }, diff --git a/packages/elements-core/src/components/MosaicTableOfContents/TableOfContents.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx similarity index 96% rename from packages/elements-core/src/components/MosaicTableOfContents/TableOfContents.tsx rename to packages/elements-core/src/components/TableOfContents/TableOfContents.tsx index 0a365fa63b..a33dcbc309 100644 --- a/packages/elements-core/src/components/MosaicTableOfContents/TableOfContents.tsx +++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx @@ -5,9 +5,9 @@ import { useRouterType } from '../../context/RouterType'; import { useFirstRender } from '../../hooks/useFirstRender'; import { VersionBadge } from '../Docs/HttpOperation/Badges'; import { + NODE_GROUP_ICON, + NODE_GROUP_ICON_COLOR, NODE_META_COLOR, - NODE_TITLE_ICON, - NODE_TITLE_ICON_COLOR, NODE_TYPE_ICON_COLOR, NODE_TYPE_META_ICON, NODE_TYPE_TITLE_ICON, @@ -34,6 +34,7 @@ import { const ActiveIdContext = React.createContext(undefined); const LinkContext = React.createContext(undefined); +LinkContext.displayName = 'LinkContext'; export const TableOfContents = React.memo( ({ @@ -103,6 +104,7 @@ export const TableOfContents = React.memo( ); }, ); +TableOfContents.displayName = 'TableOfContents'; const Divider = React.memo<{ item: TableOfContentsDivider; @@ -123,6 +125,7 @@ const Divider = React.memo<{ ); }); +Divider.displayName = 'Divider'; const GroupItem = React.memo<{ depth: number; @@ -184,6 +187,7 @@ const GroupItem = React.memo<{ return null; }); +GroupItem.displayName = 'GroupItem'; const Group = React.memo<{ depth: number; @@ -261,8 +265,9 @@ const Group = React.memo<{ depth={depth} isActive={showAsActive} icon={ - NODE_TITLE_ICON[item.title] && ( - + item.itemsType && + NODE_GROUP_ICON[item.itemsType] && ( + ) } /> @@ -289,6 +294,7 @@ const Group = React.memo<{ ); }); +Group.displayName = 'Group'; const Item = React.memo<{ depth: number; @@ -336,6 +342,7 @@ const Item = React.memo<{ ); }); +Item.displayName = 'Item'; const Node = React.memo<{ item: TableOfContentsNode | TableOfContentsNodeGroup; @@ -393,6 +400,7 @@ const Node = React.memo<{ ); }, ); +Node.displayName = 'Node'; const Version: React.FC<{ value: string }> = ({ value }) => { return ( diff --git a/packages/elements-core/src/components/MosaicTableOfContents/constants.ts b/packages/elements-core/src/components/TableOfContents/constants.ts similarity index 60% rename from packages/elements-core/src/components/MosaicTableOfContents/constants.ts rename to packages/elements-core/src/components/TableOfContents/constants.ts index 6c17d40baa..9762811320 100644 --- a/packages/elements-core/src/components/MosaicTableOfContents/constants.ts +++ b/packages/elements-core/src/components/TableOfContents/constants.ts @@ -1,19 +1,22 @@ -import { faBullseye, faCloud, faCube, faCubes } from '@fortawesome/free-solid-svg-icons'; +import { faBullseye, faCloud, faCube, faCubes, faEnvelope, faEnvelopesBulk } from '@fortawesome/free-solid-svg-icons'; import { IIconProps } from '@stoplight/mosaic'; // Icons appear left of the node title export const NODE_TYPE_TITLE_ICON: { [nodeType: string]: IIconProps['icon'] } = { http_service: faCloud, http_operation: faBullseye, + http_webhook: faEnvelope, model: faCube, }; -export const NODE_TITLE_ICON: { [nodeTitle: string]: IIconProps['icon'] } = { - Schemas: faCubes, +export const NODE_GROUP_ICON: { [itemType: string]: IIconProps['icon'] } = { + http_webhook: faEnvelopesBulk, + model: faCubes, }; // Icons appear in the right meta export const NODE_TYPE_META_ICON: { [nodeType: string]: IIconProps['icon'] } = { + webhook: faEnvelope, model: faCube, }; @@ -21,10 +24,12 @@ export const NODE_TYPE_ICON_COLOR = { model: 'warning', http_service: '#D812EA', http_operation: '#9747FF', + http_webhook: 'primary', }; -export const NODE_TITLE_ICON_COLOR = { - Schemas: 'warning', +export const NODE_GROUP_ICON_COLOR = { + webhook: 'warning', + http_webhook: 'primary', }; export const NODE_META_COLOR = { diff --git a/packages/elements-core/src/components/MosaicTableOfContents/index.ts b/packages/elements-core/src/components/TableOfContents/index.ts similarity index 100% rename from packages/elements-core/src/components/MosaicTableOfContents/index.ts rename to packages/elements-core/src/components/TableOfContents/index.ts diff --git a/packages/elements-core/src/components/MosaicTableOfContents/types.ts b/packages/elements-core/src/components/TableOfContents/types.ts similarity index 83% rename from packages/elements-core/src/components/MosaicTableOfContents/types.ts rename to packages/elements-core/src/components/TableOfContents/types.ts index 51926a72b4..e3c69e15d3 100644 --- a/packages/elements-core/src/components/MosaicTableOfContents/types.ts +++ b/packages/elements-core/src/components/TableOfContents/types.ts @@ -29,6 +29,7 @@ export type TableOfContentsGroupItem = export type TableOfContentsGroup = { title: string; items: TableOfContentsGroupItem[]; + itemsType?: 'article' | 'http_operation' | 'http_webhook' | 'model'; }; export type TableOfContentsExternalLink = { @@ -36,7 +37,9 @@ export type TableOfContentsExternalLink = { url: string; }; -export type TableOfContentsNode = { +export type TableOfContentsNode< + T = 'http_service' | 'http_operation' | 'http_webhook' | 'model' | 'article' | 'overview', +> = { id: string; slug: string; title: string; diff --git a/packages/elements-core/src/components/MosaicTableOfContents/utils.ts b/packages/elements-core/src/components/TableOfContents/utils.ts similarity index 96% rename from packages/elements-core/src/components/MosaicTableOfContents/utils.ts rename to packages/elements-core/src/components/TableOfContents/utils.ts index 716a2ee65c..3617712dc4 100644 --- a/packages/elements-core/src/components/MosaicTableOfContents/utils.ts +++ b/packages/elements-core/src/components/TableOfContents/utils.ts @@ -71,7 +71,7 @@ export function isDivider(item: TableOfContentsItem): item is TableOfContentsDiv return Object.keys(item).length === 1 && 'title' in item; } export function isGroup(item: TableOfContentsItem): item is TableOfContentsGroup { - return Object.keys(item).length === 2 && 'title' in item && 'items' in item; + return Object.keys(item).length >= 2 && 'title' in item && 'items' in item; } export function isNodeGroup(item: TableOfContentsItem): item is TableOfContentsNodeGroup { return 'title' in item && 'items' in item && 'slug' in item && 'id' in item && 'meta' in item && 'type' in item; diff --git a/packages/elements-core/src/components/TryIt/Parameters/useOperationParameters.ts b/packages/elements-core/src/components/TryIt/Parameters/useOperationParameters.ts index d8d6b5439a..c680011b6d 100644 --- a/packages/elements-core/src/components/TryIt/Parameters/useOperationParameters.ts +++ b/packages/elements-core/src/components/TryIt/Parameters/useOperationParameters.ts @@ -1,4 +1,4 @@ -import { IHttpOperation } from '@stoplight/types'; +import { IHttpEndpointOperation } from '@stoplight/types'; import { atom, useAtom } from 'jotai'; import { orderBy, uniqBy } from 'lodash'; import * as React from 'react'; @@ -7,7 +7,7 @@ import { filterOutAuthorizationParams } from '../Auth/authentication-utils'; import { initialParameterValues, ParameterSpec } from './parameter-utils'; const persistedParameterValuesAtom = atom({}); -export const useRequestParameters = (httpOperation: IHttpOperation) => { +export const useRequestParameters = (httpOperation: IHttpEndpointOperation) => { const [persistedParameterValues, setPersistedParameterValues] = useAtom(persistedParameterValuesAtom); const allParameters = React.useMemo(() => extractAllParameters(httpOperation), [httpOperation]); @@ -44,7 +44,7 @@ export const useRequestParameters = (httpOperation: IHttpOperation) => { }; }; -function extractAllParameters(httpOperation: IHttpOperation): ParameterSpec[] { +function extractAllParameters(httpOperation: IHttpEndpointOperation): ParameterSpec[] { const getRequired = (obj: { required?: boolean }) => obj.required ?? false; const pathParameters = orderBy(httpOperation.request?.path ?? [], [getRequired, 'name'], ['desc', 'asc']); diff --git a/packages/elements-core/src/components/TryIt/TryIt.tsx b/packages/elements-core/src/components/TryIt/TryIt.tsx index 99481bd3f4..cd4e66eec1 100644 --- a/packages/elements-core/src/components/TryIt/TryIt.tsx +++ b/packages/elements-core/src/components/TryIt/TryIt.tsx @@ -1,10 +1,11 @@ import { Box, Button, HStack, Icon, Panel, useThemeIsDark } from '@stoplight/mosaic'; -import type { IHttpOperation, IServer } from '@stoplight/types'; +import type { IHttpEndpointOperation, IServer } from '@stoplight/types'; import { Request as HarRequest } from 'har-format'; import { useAtom } from 'jotai'; import * as React from 'react'; import { HttpMethodColors } from '../../constants'; +import { isHttpOperation, isHttpWebhookOperation } from '../../utils/guards'; import { getServersToDisplay, getServerVariables } from '../../utils/http-spec/IServer'; import { RequestSamples } from '../RequestSamples'; import { TryItAuth } from './Auth/Auth'; @@ -33,7 +34,7 @@ import { ServerVariables } from './Servers/ServerVariables'; import { useServerVariables } from './Servers/useServerVariables'; export interface TryItProps { - httpOperation: IHttpOperation; + httpOperation: IHttpEndpointOperation; /** * The base URL of the prism mock server to redirect traffic to. @@ -136,7 +137,7 @@ export const TryIt: React.FC = ({ React.useEffect(() => { let isMounted = true; - if (onRequestChange || embeddedInMd) { + if (isHttpOperation(httpOperation) && (onRequestChange || embeddedInMd)) { buildHarRequest({ mediaTypeContent, parameterValues: parameterValuesWithDefaults, @@ -181,7 +182,7 @@ export const TryIt: React.FC = ({ const handleSendRequest = async () => { setValidateParameters(true); - if (hasRequiredButEmptyParameters) return; + if (hasRequiredButEmptyParameters || !isHttpOperation(httpOperation)) return; try { setLoading(true); @@ -240,7 +241,7 @@ export const TryIt: React.FC = ({ /> ) : null} - {serverVariables.length > 0 && ( + {isHttpOperation(httpOperation) && serverVariables.length > 0 && ( = ({ /> )} - {formDataState.isFormDataBody ? ( - - ) : mediaTypeContent ? ( - - ) : null} - - - - - - {servers.length > 1 && } + + {formDataState.isFormDataBody ? ( + + ) : mediaTypeContent ? ( + + ) : null} + - {isMockingEnabled && ( - + {isHttpOperation(httpOperation) ? ( + + + + + {servers.length > 1 && } + + {isMockingEnabled && ( + + )} + + + {validateParameters && hasRequiredButEmptyParameters && ( + + + You didn't provide all of the required parameters! + )} - - - {validateParameters && hasRequiredButEmptyParameters && ( - - - You didn't provide all of the required parameters! - - )} - + + ) : null} ); @@ -300,6 +305,16 @@ export const TryIt: React.FC = ({ // when TryIt is embedded, we need to show extra context at the top about the method + path if (embeddedInMd) { + let path: string; + + if (isHttpOperation(httpOperation)) { + path = httpOperation.path; + } else if (isHttpWebhookOperation(httpOperation)) { + path = httpOperation.name; + } else { + throw new RangeError('unsupported type'); + } + tryItPanelElem = ( @@ -307,7 +322,7 @@ export const TryIt: React.FC = ({ {httpOperation.method.toUpperCase()} - {`${chosenServer?.url || ''}${httpOperation.path}`} + {`${chosenServer?.url || ''}${path}`} diff --git a/packages/elements-core/src/constants.ts b/packages/elements-core/src/constants.ts index 244d614a58..0dab3ec1c6 100644 --- a/packages/elements-core/src/constants.ts +++ b/packages/elements-core/src/constants.ts @@ -4,6 +4,7 @@ import { faCrosshairs, faCube, faDatabase, + faEnvelope, faImage, faQuestionCircle, IconDefinition, @@ -13,6 +14,7 @@ import { Dictionary, HttpSecurityScheme, NodeType } from '@stoplight/types'; export const NodeTypeColors: Dictionary = { http_operation: '#6a6acb', + http_webhook: 'primary', http_service: '#e056fd', article: '#399da6', model: '#ef932b', @@ -30,6 +32,7 @@ export const NodeTypeColors: Dictionary = { export const NodeTypePrettyName: Dictionary = { http_operation: 'Endpoint', + http_webhook: 'Webhook', http_service: 'API', article: 'Article', model: 'Model', @@ -47,6 +50,7 @@ export const NodeTypePrettyName: Dictionary = { export const NodeTypeIconDefs: Dictionary = { http_operation: faCrosshairs, + http_webhook: faEnvelope, http_service: faCloud, article: faBookOpen, model: faCube, diff --git a/packages/elements-core/src/hooks/useParsedData.ts b/packages/elements-core/src/hooks/useParsedData.ts index 0a4c079956..8fad9b64b2 100644 --- a/packages/elements-core/src/hooks/useParsedData.ts +++ b/packages/elements-core/src/hooks/useParsedData.ts @@ -3,7 +3,7 @@ import { parse as parseYaml } from '@stoplight/yaml'; import * as React from 'react'; import { ParsedNode } from '../types'; -import { isHttpOperation, isHttpService, isJSONSchema, isSMDASTRoot } from '../utils/guards'; +import { isHttpOperation, isHttpService, isHttpWebhookOperation, isJSONSchema, isSMDASTRoot } from '../utils/guards'; export function useParsedData(nodeType: NodeType, data: unknown): ParsedNode | undefined { return React.useMemo(() => parserMap[nodeType]?.(data), [nodeType, data]); @@ -14,6 +14,7 @@ type Parser = (rawData: unknown) => ParsedNode | undefined; const parserMap: Record = { [NodeType.Article]: parseArticleData, [NodeType.HttpOperation]: parseHttpOperation, + [NodeType.HttpWebhook]: parseHttpWebhookOperation, [NodeType.HttpService]: parseHttpService, [NodeType.Model]: parseModel, [NodeType.HttpServer]: parseUnknown, @@ -50,6 +51,17 @@ function parseHttpOperation(rawData: unknown): ParsedNode | undefined { return undefined; } +function parseHttpWebhookOperation(rawData: unknown): ParsedNode | undefined { + const data = tryParseYamlOrObject(rawData); + if (isHttpWebhookOperation(data)) { + return { + type: NodeType.HttpWebhook, + data: data, + }; + } + return undefined; +} + function parseHttpService(rawData: unknown): ParsedNode | undefined { const data = tryParseYamlOrObject(rawData); if (isHttpService(data)) { diff --git a/packages/elements-core/src/index.ts b/packages/elements-core/src/index.ts index f9ab4f9869..74d3e4130a 100644 --- a/packages/elements-core/src/index.ts +++ b/packages/elements-core/src/index.ts @@ -9,16 +9,16 @@ export { MarkdownComponentsProvider, } from './components/MarkdownViewer/CustomComponents/Provider'; export { ReactRouterMarkdownLink } from './components/MarkdownViewer/CustomComponents/ReactRouterLink'; -export { TableOfContents } from './components/MosaicTableOfContents'; +export { NonIdealState } from './components/NonIdealState'; +export { PoweredByLink } from './components/PoweredByLink'; +export { TableOfContents } from './components/TableOfContents'; export { CustomLinkComponent, TableOfContentsItem, TableOfContentsNode, TableOfContentsNodeGroup, -} from './components/MosaicTableOfContents/types'; -export { findFirstNode } from './components/MosaicTableOfContents/utils'; -export { NonIdealState } from './components/NonIdealState'; -export { PoweredByLink } from './components/PoweredByLink'; +} from './components/TableOfContents/types'; +export { findFirstNode } from './components/TableOfContents/utils'; export { TryIt, TryItProps, TryItWithRequestSamples, TryItWithRequestSamplesProps } from './components/TryIt'; export { HttpMethodColors, NodeTypeColors, NodeTypeIconDefs, NodeTypePrettyName } from './constants'; export { MockingProvider } from './containers/MockingProvider'; @@ -34,7 +34,7 @@ export { useParsedValue } from './hooks/useParsedValue'; export { useRouter } from './hooks/useRouter'; export { Styled, withStyles } from './styled'; export { Divider, Group, ITableOfContentsTree, Item, ParsedNode, RoutingProps, TableOfContentItem } from './types'; -export { isHttpOperation, isHttpService } from './utils/guards'; +export { isHttpOperation, isHttpService, isHttpWebhookOperation } from './utils/guards'; export { ReferenceResolver } from './utils/ref-resolving/ReferenceResolver'; export { createResolvedObject } from './utils/ref-resolving/resolvedObject'; export { slugify } from './utils/string'; diff --git a/packages/elements-core/src/types.ts b/packages/elements-core/src/types.ts index d64a334b8a..74f5e8de78 100644 --- a/packages/elements-core/src/types.ts +++ b/packages/elements-core/src/types.ts @@ -1,5 +1,5 @@ import type { IMarkdownViewerProps } from '@stoplight/markdown-viewer'; -import { IHttpOperation, IHttpService, NodeType } from '@stoplight/types'; +import { IHttpOperation, IHttpService, IHttpWebhookOperation, NodeType } from '@stoplight/types'; import { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema'; export type JSONSchema = JSONSchema4 | JSONSchema6 | JSONSchema7; @@ -13,6 +13,10 @@ export type ParsedNode = type: NodeType.HttpOperation; data: IHttpOperation; } + | { + type: NodeType.HttpWebhook; + data: IHttpWebhookOperation; + } | { type: NodeType.HttpService; data: IHttpService; diff --git a/packages/elements-core/src/utils/guards.ts b/packages/elements-core/src/utils/guards.ts index 8deb0c0a9e..0d4e158e99 100644 --- a/packages/elements-core/src/utils/guards.ts +++ b/packages/elements-core/src/utils/guards.ts @@ -1,6 +1,6 @@ import type { IMarkdownViewerProps } from '@stoplight/markdown-viewer'; import { isArray } from '@stoplight/mosaic'; -import { IHttpOperation, IHttpService, INode } from '@stoplight/types'; +import { IHttpOperation, IHttpService, IHttpWebhookOperation, INode } from '@stoplight/types'; import { JSONSchema7 } from 'json-schema'; import { isObject, isPlainObject } from 'lodash'; @@ -25,6 +25,16 @@ export function isHttpOperation(maybeHttpOperation: unknown): maybeHttpOperation return isStoplightNode(maybeHttpOperation) && 'method' in maybeHttpOperation && 'path' in maybeHttpOperation; } +export function isHttpWebhookOperation( + maybeHttpWebhookOperation: unknown, +): maybeHttpWebhookOperation is IHttpWebhookOperation { + return ( + isStoplightNode(maybeHttpWebhookOperation) && + 'method' in maybeHttpWebhookOperation && + 'name' in maybeHttpWebhookOperation + ); +} + const properUrl = new RegExp( /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/, ); diff --git a/packages/elements-dev-portal/src/version.ts b/packages/elements-dev-portal/src/version.ts index d149376e1a..febfa65478 100644 --- a/packages/elements-dev-portal/src/version.ts +++ b/packages/elements-dev-portal/src/version.ts @@ -1,2 +1,2 @@ // auto-updated during build -export const appVersion = '1.18.1'; +export const appVersion = '1.19.5'; diff --git a/packages/elements/package.json b/packages/elements/package.json index 7f457d1fde..14ed212001 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -63,10 +63,10 @@ }, "dependencies": { "@stoplight/elements-core": "~7.16.5", - "@stoplight/http-spec": "^6.0.0", + "@stoplight/http-spec": "^7.0.1", "@stoplight/json": "^3.18.1", "@stoplight/mosaic": "^1.46.1", - "@stoplight/types": "^14.0.0", + "@stoplight/types": "^14.1.1", "@stoplight/yaml": "^4.2.3", "classnames": "^2.2.6", "file-saver": "^2.0.5", diff --git a/packages/elements/src/components/API/APIWithSidebarLayout.tsx b/packages/elements/src/components/API/APIWithSidebarLayout.tsx index 7f74647fc9..fceebe811b 100644 --- a/packages/elements/src/components/API/APIWithSidebarLayout.tsx +++ b/packages/elements/src/components/API/APIWithSidebarLayout.tsx @@ -5,6 +5,7 @@ import { PoweredByLink, SidebarLayout, TableOfContents, + TableOfContentsItem, } from '@stoplight/elements-core'; import { Flex, Heading } from '@stoplight/mosaic'; import { NodeType } from '@stoplight/types'; @@ -65,13 +66,45 @@ export const APIWithSidebarLayout: React.FC = ({ return ; } + const sidebar = ( + + ); + + return ( + + {node && ( + + )} + + ); +}; + +type SidebarProps = { + serviceNode: ServiceNode; + logo?: string; + container: React.RefObject; + pathname: string; + tree: TableOfContentsItem[]; +}; + +export const Sidebar: React.FC = ({ serviceNode, logo, container, pathname, tree }) => { const handleTocClick = () => { if (container.current) { container.current.scrollIntoView(); } }; - const sidebar = ( + return ( <> {logo ? ( @@ -87,22 +120,5 @@ export const APIWithSidebarLayout: React.FC = ({ ); - - return ( - - {node && ( - - )} - - ); }; +Sidebar.displayName = 'Sidebar'; diff --git a/packages/elements/src/components/API/APIWithStackedLayout.tsx b/packages/elements/src/components/API/APIWithStackedLayout.tsx index b4b8f13215..bd058c1f3b 100644 --- a/packages/elements/src/components/API/APIWithStackedLayout.tsx +++ b/packages/elements/src/components/API/APIWithStackedLayout.tsx @@ -6,15 +6,16 @@ import { ParsedDocs, TryItWithRequestSamples, } from '@stoplight/elements-core'; -import { Box, Flex, Icon, Tab, TabList, TabPanel, TabPanels, Tabs } from '@stoplight/mosaic'; +import { Box, Flex, Heading, Icon, Tab, TabList, TabPanel, TabPanels, Tabs } from '@stoplight/mosaic'; import { NodeType } from '@stoplight/types'; import cn from 'classnames'; import * as React from 'react'; -import { OperationNode, ServiceNode } from '../../utils/oas/types'; +import { OperationNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; import { computeTagGroups, TagGroup } from './utils'; type TryItCredentialsPolicy = 'omit' | 'include' | 'same-origin'; + interface Location { pathname: string; search: string; @@ -34,8 +35,12 @@ type StackedLayoutProps = { location: Location; }; -const itemMatchesHash = (hash: string, item: OperationNode) => { - return hash.substr(1) === `${item.data.path}-${item.data.method}`; +const itemMatchesHash = (hash: string, item: OperationNode | WebhookNode) => { + if (item.type === NodeType.HttpOperation) { + return hash.substr(1) === `${item.data.path}-${item.data.method}`; + } else { + return hash.substr(1) === `${item.data.name}-${item.data.method}`; + } }; const TryItContext = React.createContext<{ @@ -71,7 +76,8 @@ export const APIWithStackedLayout: React.FC = ({ showPoweredByLink = true, location, }) => { - const { groups } = computeTagGroups(serviceNode); + const { groups: operationGroups } = computeTagGroups(serviceNode, NodeType.HttpOperation); + const { groups: webhookGroups } = computeTagGroups(serviceNode, NodeType.HttpWebhook); return ( @@ -89,8 +95,12 @@ export const APIWithStackedLayout: React.FC = ({ tryItCredentialsPolicy={tryItCredentialsPolicy} /> - - {groups.map(group => ( + {operationGroups.length > 0 && webhookGroups.length > 0 ? Endpoints : null} + {operationGroups.map(group => ( + + ))} + {webhookGroups.length > 0 ? Webhooks : null} + {webhookGroups.map(group => ( ))} @@ -98,8 +108,9 @@ export const APIWithStackedLayout: React.FC = ({ ); }; +APIWithStackedLayout.displayName = 'APIWithStackedLayout'; -const Group = React.memo<{ group: TagGroup }>(({ group }) => { +const Group = React.memo<{ group: TagGroup }>(({ group }) => { const [isExpanded, setIsExpanded] = React.useState(false); const scrollRef = React.useRef(null); const { @@ -151,8 +162,9 @@ const Group = React.memo<{ group: TagGroup }>(({ group }) => { ); }); +Group.displayName = 'Group'; -const Item = React.memo<{ item: OperationNode }>(({ item }) => { +const Item = React.memo<{ item: OperationNode | WebhookNode }>(({ item }) => { const { location } = React.useContext(LocationContext); const { hash } = location; const [isExpanded, setIsExpanded] = React.useState(false); @@ -197,7 +209,7 @@ const Item = React.memo<{ item: OperationNode }>(({ item }) => { - {item.data.path} + {item.type === NodeType.HttpOperation ? item.data.path : item.name} {isDeprecated && } @@ -224,6 +236,7 @@ const Item = React.memo<{ item: OperationNode }>(({ item }) => { layoutOptions={{ noHeading: true, hideTryItPanel: true }} /> + (({ item }) => { ); }); +Item.displayName = 'Item'; const Collapse: React.FC<{ isOpen: boolean }> = ({ isOpen, children }) => { if (!isOpen) return null; return {children}; }; +Collapse.displayName = 'Collapse'; diff --git a/packages/elements/src/components/API/__tests__/utils.test.ts b/packages/elements/src/components/API/__tests__/utils.test.ts index e3c0d6d768..9d0df361f0 100644 --- a/packages/elements/src/components/API/__tests__/utils.test.ts +++ b/packages/elements/src/components/API/__tests__/utils.test.ts @@ -1,836 +1,851 @@ -import { OpenAPIObject } from 'openapi3-ts'; +import { NodeType } from '@stoplight/types'; +import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; import { transformOasToServiceNode } from '../../../utils/oas'; +import { OperationNode, WebhookNode } from '../../../utils/oas/types'; import { computeAPITree, computeTagGroups } from '../utils'; -describe('computeTagGroups', () => { - it('orders endpoints according to specificed tags', () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - tags: [ - { - name: 'beta', - }, - { - name: 'alpha', - }, - ], - paths: { - '/a': { - get: { - tags: ['alpha'], +type OpenAPIObject = Partial<_OpenAPIObject> & { + webhooks?: PathObject; +}; +describe.each([ + ['paths', NodeType.HttpOperation, 'Endpoints', 'path'], + ['webhooks', NodeType.HttpWebhook, 'Webhooks', 'name'], +] as const)('when grouping from "%s" as %s', (pathProp, nodeType, title, parentKeyProp) => { + describe('computeTagGroups', () => { + it('orders endpoints according to specified tags', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + tags: [ + { + name: 'beta', }, - }, - '/b': { - get: { - tags: ['beta'], + { + name: 'alpha', + }, + ], + [pathProp]: { + '/a': { + get: { + tags: ['alpha'], + }, + }, + '/b': { + get: { + tags: ['beta'], + }, }, }, - }, - }; + }; - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode) : null).toEqual({ - groups: [ - { - title: 'beta', - items: [ - { - type: 'http_operation', - uri: '/paths/b/get', - data: { - id: '2b447d075652c', - method: 'get', - path: '/b', - responses: [], - servers: [], - request: { - headers: [], - query: [], - cookie: [], - path: [], + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + groups: [ + { + title: 'beta', + items: [ + { + type: nodeType, + uri: `/${pathProp}/b/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/b', + responses: [], + servers: [], + request: { + headers: [], + query: [], + cookie: [], + path: [], + }, + tags: [ + { + id: '9695eccd3aa64', + name: 'beta', + }, + ], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, }, - tags: [ - { - id: '9695eccd3aa64', - name: 'beta', + name: '/b', + tags: ['beta'], + }, + ], + }, + { + title: 'alpha', + items: [ + { + type: nodeType, + uri: `/${pathProp}/a/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/a', + responses: [], + servers: [], + request: { + headers: [], + query: [], + cookie: [], + path: [], }, - ], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, + tags: [ + { + id: 'df0b92b61db3a', + name: 'alpha', + }, + ], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/a', + tags: ['alpha'], }, - name: '/b', + ], + }, + ], + ungrouped: [], + }); + }); + + it("within the tags it doesn't reorder the endpoints", () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + tags: [ + { + name: 'beta', + }, + { + name: 'alpha', + }, + ], + [pathProp]: { + '/a': { + get: { + tags: ['alpha'], + }, + }, + '/c': { + get: { tags: ['beta'], }, - ], + }, + '/b': { + get: { + tags: ['beta'], + }, + }, }, - { - title: 'alpha', - items: [ - { - type: 'http_operation', - uri: '/paths/a/get', - data: { - id: '2b547d0756761', - method: 'get', - path: '/a', - responses: [], - servers: [], - request: { - headers: [], - query: [], - cookie: [], - path: [], + }; + + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + groups: [ + { + title: 'beta', + items: [ + { + type: nodeType, + uri: `/${pathProp}/c/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/c', + responses: [], + servers: [], + request: { headers: [], query: [], cookie: [], path: [] }, + tags: [{ id: '9695eccd3aa64', name: 'beta' }], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, }, - tags: [ - { - id: 'df0b92b61db3a', - name: 'alpha', - }, - ], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, + name: '/c', + tags: ['beta'], }, - name: '/a', - tags: ['alpha'], - }, - ], - }, - ], - ungrouped: [], + { + type: nodeType, + uri: `/${pathProp}/b/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/b', + responses: [], + servers: [], + request: { headers: [], query: [], cookie: [], path: [] }, + tags: [{ id: '9695eccd3aa64', name: 'beta' }], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/b', + tags: ['beta'], + }, + ], + }, + { + title: 'alpha', + items: [ + { + type: nodeType, + uri: `/${pathProp}/a/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/a', + responses: [], + servers: [], + request: { headers: [], query: [], cookie: [], path: [] }, + tags: [{ id: 'df0b92b61db3a', name: 'alpha' }], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/a', + tags: ['alpha'], + }, + ], + }, + ], + ungrouped: [], + }); }); - }); - it("within the tags it doesn't reorder the endpoints", () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - tags: [ - { - name: 'beta', - }, - { - name: 'alpha', - }, - ], - paths: { - '/a': { - get: { - tags: ['alpha'], + it("within the tags it doesn't reorder the methods", () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + tags: [ + { + name: 'beta', }, - }, - '/c': { - get: { - tags: ['beta'], + { + name: 'alpha', }, - }, - '/b': { - get: { - tags: ['beta'], + ], + [pathProp]: { + '/a': { + get: { + tags: ['alpha'], + }, }, - }, - }, - }; - - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode) : null).toEqual({ - groups: [ - { - title: 'beta', - items: [ - { - type: 'http_operation', - uri: '/paths/c/get', - data: { - id: '2b347d0756b9f', - method: 'get', - path: '/c', - responses: [], - servers: [], - request: { headers: [], query: [], cookie: [], path: [] }, - tags: [{ id: '9695eccd3aa64', name: 'beta' }], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, - }, - name: '/c', + '/b': { + get: { tags: ['beta'], }, - { - type: 'http_operation', - uri: '/paths/b/get', - data: { - id: '2b447d075652c', - method: 'get', - path: '/b', - responses: [], - servers: [], - request: { headers: [], query: [], cookie: [], path: [] }, - tags: [{ id: '9695eccd3aa64', name: 'beta' }], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, - }, - name: '/b', + delete: { tags: ['beta'], }, - ], + }, }, - { - title: 'alpha', - items: [ - { - type: 'http_operation', - uri: '/paths/a/get', - data: { - id: '2b547d0756761', - method: 'get', - path: '/a', - responses: [], - servers: [], - request: { headers: [], query: [], cookie: [], path: [] }, - tags: [{ id: 'df0b92b61db3a', name: 'alpha' }], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, + }; + + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + groups: [ + { + title: 'beta', + items: [ + { + type: nodeType, + uri: `/${pathProp}/b/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/b', + responses: [], + servers: [], + request: { headers: [], query: [], cookie: [], path: [] }, + tags: [{ id: '9695eccd3aa64', name: 'beta' }], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/b', + tags: ['beta'], }, - name: '/a', + { + type: nodeType, + uri: `/${pathProp}/b/delete`, + data: { + id: expect.any(String), + method: 'delete', + [parentKeyProp]: '/b', + responses: [], + servers: [], + request: { headers: [], query: [], cookie: [], path: [] }, + tags: [{ id: '9695eccd3aa64', name: 'beta' }], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/b', + tags: ['beta'], + }, + ], + }, + { + title: 'alpha', + items: [ + { + type: nodeType, + uri: `/${pathProp}/a/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/a', + responses: [], + servers: [], + request: { headers: [], query: [], cookie: [], path: [] }, + tags: [{ id: 'df0b92b61db3a', name: 'alpha' }], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/a', + tags: ['alpha'], + }, + ], + }, + ], + ungrouped: [], + }); + }); + + it("doesn't throw with incorrect tags value", () => { + const apiDocument = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + [pathProp]: {}, + tags: { + $ref: './tags', + }, + }; + + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + groups: [], + ungrouped: [], + }); + }); + + it('leaves tag casing unchanged', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + tags: [ + { + name: 'Beta', + }, + { + name: 'alpha', + }, + ], + [pathProp]: { + '/a': { + get: { tags: ['alpha'], }, - ], + }, + '/b': { + get: { + tags: ['Beta'], + }, + }, }, - ], - ungrouped: [], + }; + + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + groups: [ + { + title: 'Beta', + items: [ + { + type: nodeType, + uri: `/${pathProp}/b/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/b', + responses: [], + servers: [], + request: { + headers: [], + query: [], + cookie: [], + path: [], + }, + tags: [ + { + name: 'Beta', + id: 'c6a65e6457b55', + }, + ], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/b', + tags: ['Beta'], + }, + ], + }, + { + title: 'alpha', + items: [ + { + type: nodeType, + uri: `/${pathProp}/a/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/a', + responses: [], + servers: [], + request: { + headers: [], + query: [], + cookie: [], + path: [], + }, + tags: [ + { + id: 'df0b92b61db3a', + name: 'alpha', + }, + ], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/a', + tags: ['alpha'], + }, + ], + }, + ], + ungrouped: [], + }); }); - }); - it("within the tags it doesn't reorder the methods", () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - tags: [ - { - name: 'beta', - }, - { - name: 'alpha', - }, - ], - paths: { - '/a': { - get: { - tags: ['alpha'], + it('matches mixed tag casing', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + tags: [ + { + name: 'Beta', }, - }, - '/b': { - get: { - tags: ['beta'], + { + name: 'alpha', }, - delete: { - tags: ['beta'], + ], + [pathProp]: { + '/a': { + get: { + tags: ['alpha'], + }, + }, + '/b': { + get: { + tags: ['beta'], + }, }, }, - }, - }; + }; - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode) : null).toEqual({ - groups: [ - { - title: 'beta', - items: [ - { - type: 'http_operation', - uri: '/paths/b/get', - data: { - id: '2b447d075652c', - method: 'get', - path: '/b', - responses: [], - servers: [], - request: { headers: [], query: [], cookie: [], path: [] }, - tags: [{ id: '9695eccd3aa64', name: 'beta' }], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + groups: [ + { + title: 'Beta', + items: [ + { + type: nodeType, + uri: `/${pathProp}/b/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/b', + responses: [], + servers: [], + request: { + headers: [], + query: [], + cookie: [], + path: [], + }, + tags: [ + { + id: '9695eccd3aa64', + name: 'beta', + }, + ], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/b', + tags: ['beta'], }, - name: '/b', - tags: ['beta'], - }, - { - type: 'http_operation', - uri: '/paths/b/delete', - data: { - id: 'd646c34fd1825', - method: 'delete', - path: '/b', - responses: [], - servers: [], - request: { headers: [], query: [], cookie: [], path: [] }, - tags: [{ id: '9695eccd3aa64', name: 'beta' }], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, + ], + }, + { + title: 'alpha', + items: [ + { + type: nodeType, + uri: `/${pathProp}/a/get`, + data: { + id: expect.any(String), + method: 'get', + [parentKeyProp]: '/a', + responses: [], + servers: [], + request: { + headers: [], + query: [], + cookie: [], + path: [], + }, + tags: [ + { + id: 'df0b92b61db3a', + name: 'alpha', + }, + ], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/a', + tags: ['alpha'], + }, + ], + }, + ], + ungrouped: [], + }); + }); + }); + + describe('computeAPITree', () => { + it('generates API ToC tree', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + [pathProp]: { + '/something': { + get: { + responses: { + 200: { + schema: { $ref: '#/definitions/schemas/ImportantSchema' }, + }, }, - name: '/b', - tags: ['beta'], }, - ], + }, }, - { - title: 'alpha', - items: [ - { - type: 'http_operation', - uri: '/paths/a/get', - data: { - id: '2b547d0756761', - method: 'get', - path: '/a', - responses: [], - servers: [], - request: { headers: [], query: [], cookie: [], path: [] }, - tags: [{ id: 'df0b92b61db3a', name: 'alpha' }], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, + components: { + schemas: { + ImportantSchema: { + type: 'object', + properties: { + a: { type: 'string' }, }, - name: '/a', - tags: ['alpha'], }, - ], + }, }, - ], - ungrouped: [], - }); - }); + }; - it("doesn't throw with incorrect tags value", () => { - const apiDocument = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - paths: {}, - tags: { - $ref: './tags', - }, - }; - - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode) : null).toEqual({ groups: [], ungrouped: [] }); - }); - - it('leaves tag casing unchanged', () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - tags: [ + expect(computeAPITree(transformOasToServiceNode(apiDocument)!)).toEqual([ { - name: 'Beta', + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', }, { - name: 'alpha', + title, }, - ], - paths: { - '/a': { - get: { - tags: ['alpha'], - }, + { + id: `/${pathProp}/something/get`, + meta: 'get', + slug: `/${pathProp}/something/get`, + title: '/something', + type: nodeType, }, - '/b': { - get: { - tags: ['Beta'], - }, + { title: 'Schemas' }, + { + id: '/schemas/ImportantSchema', + slug: '/schemas/ImportantSchema', + title: 'ImportantSchema', + type: 'model', + meta: '', }, - }, - }; + ]); + }); - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode) : null).toEqual({ - groups: [ - { - title: 'Beta', - items: [ - { - type: 'http_operation', - uri: '/paths/b/get', - data: { - id: '2b447d075652c', - method: 'get', - path: '/b', - responses: [], - servers: [], - request: { - headers: [], - query: [], - cookie: [], - path: [], + it('allows to hide schemas from ToC', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + [pathProp]: { + '/something': { + get: { + responses: { + 200: { + schema: { $ref: '#/definitions/schemas/ImportantSchema' }, }, - tags: [ - { - name: 'Beta', - id: 'c6a65e6457b55', - }, - ], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, }, - name: '/b', - tags: ['Beta'], }, - ], + }, }, - { - title: 'alpha', - items: [ - { - type: 'http_operation', - uri: '/paths/a/get', - data: { - id: '2b547d0756761', - method: 'get', - path: '/a', - responses: [], - servers: [], - request: { - headers: [], - query: [], - cookie: [], - path: [], - }, - tags: [ - { - id: 'df0b92b61db3a', - name: 'alpha', - }, - ], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, + components: { + schemas: { + ImportantSchema: { + type: 'object', + properties: { + a: { type: 'string' }, }, - name: '/a', - tags: ['alpha'], }, - ], + }, }, - ], - ungrouped: [], - }); - }); + }; - it('matches mixed tag casing', () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - tags: [ + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideSchemas: true })).toEqual([ { - name: 'Beta', + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', }, { - name: 'alpha', + title, }, - ], - paths: { - '/a': { - get: { - tags: ['alpha'], - }, + { + id: `/${pathProp}/something/get`, + meta: 'get', + slug: `/${pathProp}/something/get`, + title: '/something', + type: nodeType, }, - '/b': { - get: { - tags: ['beta'], + ]); + }); + + it('allows to hide internal operations from ToC', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + [pathProp]: { + '/something': { + get: {}, + post: { + 'x-internal': true, + }, }, }, - }, - }; + }; - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode) : null).toEqual({ - groups: [ + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ { - title: 'Beta', - items: [ - { - type: 'http_operation', - uri: '/paths/b/get', - data: { - id: '2b447d075652c', - method: 'get', - path: '/b', - responses: [], - servers: [], - request: { - headers: [], - query: [], - cookie: [], - path: [], - }, - tags: [ - { - id: '9695eccd3aa64', - name: 'beta', - }, - ], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, - }, - name: '/b', - tags: ['beta'], - }, - ], + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', }, { - title: 'alpha', - items: [ - { - type: 'http_operation', - uri: '/paths/a/get', - data: { - id: '2b547d0756761', - method: 'get', - path: '/a', - responses: [], - servers: [], - request: { - headers: [], - query: [], - cookie: [], - path: [], - }, - tags: [ - { - id: 'df0b92b61db3a', - name: 'alpha', - }, - ], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, - }, - name: '/a', - tags: ['alpha'], - }, - ], + title, + }, + { + id: `/${pathProp}/something/get`, + meta: 'get', + slug: `/${pathProp}/something/get`, + title: '/something', + type: nodeType, }, - ], - ungrouped: [], + ]); }); - }); -}); -describe('computeAPITree', () => { - it('generates API ToC tree', () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - paths: { - '/something': { - get: { - responses: { - 200: { - schema: { $ref: '#/definitions/schemas/ImportantSchema' }, - }, - }, + it('allows to hide nested internal operations from ToC', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + tags: [ + { + name: 'a', }, - }, - }, - components: { - schemas: { - ImportantSchema: { - type: 'object', - properties: { - a: { type: 'string' }, + ], + [pathProp]: { + '/something': { + get: { + tags: ['a'], + }, + post: { + 'x-internal': true, + tags: ['a'], }, }, }, - }, - }; + }; - expect(computeAPITree(transformOasToServiceNode(apiDocument)!)).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - { - title: 'Endpoints', - }, - { - id: '/paths/something/get', - meta: 'get', - slug: '/paths/something/get', - title: '/something', - type: 'http_operation', - }, - { title: 'Schemas' }, - { - id: '/schemas/ImportantSchema', - slug: '/schemas/ImportantSchema', - title: 'ImportantSchema', - type: 'model', - meta: '', - }, - ]); - }); - - it('allows to hide schemas from ToC', () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - paths: { - '/something': { - get: { - responses: { - 200: { - schema: { $ref: '#/definitions/schemas/ImportantSchema' }, - }, + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title, + }, + { + title: 'a', + itemsType: nodeType, + items: [ + { + id: `/${pathProp}/something/get`, + meta: 'get', + slug: `/${pathProp}/something/get`, + title: '/something', + type: nodeType, }, - }, + ], }, - }, - components: { - schemas: { - ImportantSchema: { - type: 'object', - properties: { - a: { type: 'string' }, + ]); + }); + + it('allows to hide internal models from ToC', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + [pathProp]: {}, + components: { + schemas: { + SomeInternalSchema: { + 'x-internal': true, }, }, }, - }, - }; - - expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideSchemas: true })).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - { - title: 'Endpoints', - }, - { - id: '/paths/something/get', - meta: 'get', - slug: '/paths/something/get', - title: '/something', - type: 'http_operation', - }, - ]); - }); - - it('allows to hide internal operations from ToC', () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - paths: { - '/something': { - get: {}, - post: { - 'x-internal': true, - }, - }, - }, - }; + }; - expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - { - title: 'Endpoints', - }, - { - id: '/paths/something/get', - meta: 'get', - slug: '/paths/something/get', - title: '/something', - type: 'http_operation', - }, - ]); - }); - - it('allows to hide nested internal operations from ToC', () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - tags: [ + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ { - name: 'a', + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', }, - ], - paths: { - '/something': { - get: { - tags: ['a'], - }, - post: { - 'x-internal': true, - tags: ['a'], - }, - }, - }, - }; + ]); + }); - expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - { - title: 'Endpoints', - }, - { - title: 'a', - items: [ + it('excludes groups with no items', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + tags: [ { - id: '/paths/something/get', - meta: 'get', - slug: '/paths/something/get', - title: '/something', - type: 'http_operation', + name: 'a', }, ], - }, - ]); - }); - - it('allows to hide internal models from ToC', () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - paths: {}, - components: { - schemas: { - SomeInternalSchema: { - 'x-internal': true, - }, - }, - }, - }; - - expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - ]); - }); + [pathProp]: { + '/something': { + post: { + 'x-internal': true, + tags: ['a'], + }, + }, + '/something-else': { + post: { + tags: ['b'], + }, + }, + }, + }; - it('excludes groups with no items', () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - tags: [ + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ { - name: 'a', + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', }, - ], - paths: { - '/something': { - post: { - 'x-internal': true, - tags: ['a'], - }, + { + title, }, - '/something-else': { - post: { - tags: ['b'], - }, + { + title: 'b', + itemsType: nodeType, + items: [ + { + id: `/${pathProp}/something-else/post`, + meta: 'post', + slug: `/${pathProp}/something-else/post`, + title: '/something-else', + type: nodeType, + }, + ], }, - }, - }; - - expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - { - title: 'Endpoints', - }, - { - title: 'b', - items: [ - { - id: '/paths/something-else/post', - meta: 'post', - slug: '/paths/something-else/post', - title: '/something-else', - type: 'http_operation', - }, - ], - }, - ]); + ]); + }); }); }); diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index d605d268dd..f40449b6db 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -1,19 +1,22 @@ -import { isHttpOperation, isHttpService, TableOfContentsItem } from '@stoplight/elements-core'; +import { isHttpOperation, isHttpService, isHttpWebhookOperation, TableOfContentsItem } from '@stoplight/elements-core'; import { NodeType } from '@stoplight/types'; import { defaults } from 'lodash'; -import { OperationNode, ServiceChildNode, ServiceNode } from '../../utils/oas/types'; +import { OperationNode, ServiceChildNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; -export type TagGroup = { title: string; items: OperationNode[] }; +type GroupableNode = OperationNode | WebhookNode; -export const computeTagGroups = (serviceNode: ServiceNode) => { - const groupsByTagId: { [tagId: string]: TagGroup } = {}; - const ungrouped = []; +export type TagGroup = { title: string; items: T[] }; + +export function computeTagGroups(serviceNode: ServiceNode, nodeType: T['type']) { + const groupsByTagId: { [tagId: string]: TagGroup } = {}; + const ungrouped: T[] = []; const lowerCaseServiceTags = serviceNode.tags.map(tn => tn.toLowerCase()); - for (const node of serviceNode.children) { - if (node.type !== NodeType.HttpOperation) continue; + const groupableNodes = serviceNode.children.filter(n => n.type === nodeType) as T[]; + + for (const node of groupableNodes) { const tagName = node.tags[0]; if (tagName) { @@ -51,7 +54,7 @@ export const computeTagGroups = (serviceNode: ServiceNode) => { .map(([, tagGroup]) => tagGroup); return { groups: orderedTagGroups, ungrouped }; -}; +} interface ComputeAPITreeConfig { hideSchemas?: boolean; @@ -75,15 +78,60 @@ export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeC meta: '', }); - const operationNodes = serviceNode.children.filter(node => node.type === NodeType.HttpOperation); - if (operationNodes.length) { + const hasOperationNodes = serviceNode.children.some(node => node.type === NodeType.HttpOperation); + if (hasOperationNodes) { tree.push({ title: 'Endpoints', }); - const { groups, ungrouped } = computeTagGroups(serviceNode); + const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpOperation); + + // Show ungrouped operations above tag groups + ungrouped.forEach(operationNode => { + if (mergedConfig.hideInternal && operationNode.data.internal) { + return; + } + tree.push({ + id: operationNode.uri, + slug: operationNode.uri, + title: operationNode.name, + type: operationNode.type, + meta: operationNode.data.method, + }); + }); + + groups.forEach(group => { + const items = group.items.flatMap(operationNode => { + if (mergedConfig.hideInternal && operationNode.data.internal) { + return []; + } + return { + id: operationNode.uri, + slug: operationNode.uri, + title: operationNode.name, + type: operationNode.type, + meta: operationNode.data.method, + }; + }); + if (items.length > 0) { + tree.push({ + title: group.title, + items, + itemsType: 'http_operation', + }); + } + }); + } + + const hasWebhookNodes = serviceNode.children.some(node => node.type === NodeType.HttpWebhook); + if (hasWebhookNodes) { + tree.push({ + title: 'Webhooks', + }); + + const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpWebhook); - // Show ungroupped operations above tag groups + // Show ungrouped operations above tag groups ungrouped.forEach(operationNode => { if (mergedConfig.hideInternal && operationNode.data.internal) { return; @@ -114,6 +162,7 @@ export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeC tree.push({ title: group.title, items, + itemsType: 'http_webhook', }); } }); @@ -121,7 +170,7 @@ export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeC let schemaNodes = serviceNode.children.filter(node => node.type === NodeType.Model); if (mergedConfig.hideInternal) { - schemaNodes = schemaNodes.filter(node => !node.data['x-internal']); + schemaNodes = schemaNodes.filter(n => !isInternal(n)); } if (!mergedConfig.hideSchemas && schemaNodes.length) { @@ -162,7 +211,7 @@ export const findFirstNodeSlug = (tree: TableOfContentsItem[]): string | void => export const isInternal = (node: ServiceChildNode | ServiceNode): boolean => { const data = node.data; - if (isHttpOperation(data)) { + if (isHttpOperation(data) || isHttpWebhookOperation(data)) { return !!data.internal; } diff --git a/packages/elements/src/utils/oas/index.ts b/packages/elements/src/utils/oas/index.ts index 1424d2bb03..4fb9214bb5 100644 --- a/packages/elements/src/utils/oas/index.ts +++ b/packages/elements/src/utils/oas/index.ts @@ -1,22 +1,28 @@ import { slugify } from '@stoplight/elements-core'; -import type { +import { Oas2HttpOperationTransformer, Oas2HttpServiceTransformer, - Oas3HttpOperationTransformer, + Oas3HttpEndpointOperationTransformer, Oas3HttpServiceTransformer, + OPERATION_CONFIG, + WEBHOOK_CONFIG, } from '@stoplight/http-spec/oas'; import { transformOas2Operation, transformOas2Service } from '@stoplight/http-spec/oas2'; import { transformOas3Operation, transformOas3Service } from '@stoplight/http-spec/oas3'; import { encodePointerFragment, pointerToPath } from '@stoplight/json'; -import { NodeType } from '@stoplight/types'; +import { IHttpOperation, IHttpWebhookOperation, NodeType } from '@stoplight/types'; import { get, isObject, last } from 'lodash'; -import { OpenAPIObject } from 'openapi3-ts'; +import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; import { Spec } from 'swagger-schema-official'; import { oas2SourceMap } from './oas2'; import { oas3SourceMap } from './oas3'; import { ISourceNodeMap, NodeTypes, ServiceChildNode, ServiceNode } from './types'; +type OpenAPIObject = _OpenAPIObject & { + webhooks?: PathObject; +}; + const isOas2 = (parsed: unknown): parsed is Spec => isObject(parsed) && 'swagger' in parsed && @@ -56,7 +62,7 @@ function computeServiceNode( document: Spec | OpenAPIObject, map: ISourceNodeMap[], transformService: Oas2HttpServiceTransformer | Oas3HttpServiceTransformer, - transformOperation: Oas2HttpOperationTransformer | Oas3HttpOperationTransformer, + transformOperation: Oas2HttpOperationTransformer | Oas3HttpEndpointOperationTransformer, ) { const serviceDocument = transformService({ document }); const serviceNode: ServiceNode = { @@ -75,7 +81,7 @@ function computeChildNodes( document: Spec | OpenAPIObject, data: unknown, map: ISourceNodeMap[], - transformer: Oas2HttpOperationTransformer | Oas3HttpOperationTransformer, + transformer: Oas2HttpOperationTransformer | Oas3HttpEndpointOperationTransformer, parentUri: string = '', ) { const nodes: ServiceChildNode[] = []; @@ -85,14 +91,20 @@ function computeChildNodes( for (const key of Object.keys(data)) { const sanitizedKey = encodePointerFragment(key); const match = findMapMatch(sanitizedKey, map); + if (match) { const uri = `${parentUri}/${sanitizedKey}`; - const jsonPath = pointerToPath(`#${uri}`); + if (match.type === NodeTypes.Operation && jsonPath.length === 3) { const path = String(jsonPath[1]); const method = String(jsonPath[2]); - const operationDocument = transformer({ document, path, method }); + const operationDocument = transformer({ + document, + name: path, + method, + config: OPERATION_CONFIG, + }) as IHttpOperation; let parsedUri; const encodedPath = String(encodePointerFragment(path)); @@ -109,6 +121,32 @@ function computeChildNodes( name: operationDocument.summary || operationDocument.iid || operationDocument.path, tags: operationDocument.tags?.map(tag => tag.name) || [], }); + } else if (match.type === NodeTypes.Webhook && jsonPath.length === 3) { + const name = String(jsonPath[1]); + const method = String(jsonPath[2]); + const webhookDocument = transformer({ + document, + name, + method, + config: WEBHOOK_CONFIG, + }) as IHttpWebhookOperation; + + let parsedUri; + const encodedPath = String(encodePointerFragment(name)); + + if (webhookDocument.iid) { + parsedUri = `/webhooks/${webhookDocument.iid}`; + } else { + parsedUri = uri.replace(encodedPath, slugify(name)); + } + + nodes.push({ + type: NodeType.HttpWebhook, + uri: parsedUri, + data: webhookDocument, + name: webhookDocument.summary || webhookDocument.name, + tags: webhookDocument.tags?.map(tag => tag.name) || [], + }); } else if (match.type === NodeTypes.Model) { const schemaDocument = get(document, jsonPath); const parsedUri = uri.replace(OAS_MODEL_REGEXP, 'schemas/'); diff --git a/packages/elements/src/utils/oas/oas3.ts b/packages/elements/src/utils/oas/oas3.ts index 08ab321407..a4323751d8 100644 --- a/packages/elements/src/utils/oas/oas3.ts +++ b/packages/elements/src/utils/oas/oas3.ts @@ -18,6 +18,23 @@ export const oas3SourceMap: ISourceNodeMap[] = [ ], }, + { + match: 'webhooks', + type: NodeTypes.Webhooks, + children: [ + { + notMatch: '^x-', + type: NodeTypes.Webhook, + children: [ + { + match: 'get|post|put|delete|options|head|patch|trace', + type: NodeTypes.Webhook, + }, + ], + }, + ], + }, + { match: 'components', type: NodeTypes.Components, diff --git a/packages/elements/src/utils/oas/types.ts b/packages/elements/src/utils/oas/types.ts index 9dfc99945e..8d22cc5467 100644 --- a/packages/elements/src/utils/oas/types.ts +++ b/packages/elements/src/utils/oas/types.ts @@ -1,10 +1,12 @@ -import { IHttpOperation, IHttpService, NodeType } from '@stoplight/types'; +import { IHttpOperation, IHttpService, IHttpWebhookOperation, NodeType } from '@stoplight/types'; import { JSONSchema7 } from 'json-schema'; export enum NodeTypes { Paths = 'paths', Path = 'path', Operation = 'operation', + Webhooks = 'webhooks', + Webhook = 'webhook', Components = 'components', Models = 'models', Model = 'model', @@ -26,6 +28,7 @@ type Node = { }; export type ServiceNode = Node & { children: ServiceChildNode[] }; -export type ServiceChildNode = OperationNode | SchemaNode; +export type ServiceChildNode = OperationNode | WebhookNode | SchemaNode; export type OperationNode = Node; +export type WebhookNode = Node; export type SchemaNode = Node; diff --git a/yarn.lock b/yarn.lock index d2411c4d94..80bea76f95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3964,14 +3964,14 @@ dependencies: eslint-config-prettier "^8.3.0" -"@stoplight/http-spec@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@stoplight/http-spec/-/http-spec-6.0.0.tgz#e472032b1dab536c9d0bbf76fbae0d9eae2f7e7d" - integrity sha512-bCNw24C0xSHJTJDKs7Bz6RViyZ593uS7rpfGvcA7bOIdNT5wzUwXY27TMIzGA5HV2aGawc4RPdc8CufluFaNTg== +"@stoplight/http-spec@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@stoplight/http-spec/-/http-spec-7.0.1.tgz#6802f3e426940e02d88126fc486237e73541ee81" + integrity sha512-9O34o331tDMwHEHEc0N5Mh39cvVtioono46SyMy/BWQwAhuVyVfzHZxlBHepe0grIDcTrJxsb8iARGi86luwoQ== dependencies: "@stoplight/json" "^3.18.1" "@stoplight/json-schema-generator" "1.0.2" - "@stoplight/types" "14.0.0" + "@stoplight/types" "14.1.0" "@types/json-schema" "7.0.11" "@types/swagger-schema-official" "~2.0.22" "@types/type-is" "^1.6.3" @@ -3981,7 +3981,7 @@ lodash.pickby "^4.6.0" openapi3-ts "^2.0.2" postman-collection "^4.1.2" - tslib "^2.3.1" + tslib "^2.6.2" type-is "^1.6.18" "@stoplight/json-schema-generator@1.0.2": @@ -4260,10 +4260,10 @@ shelljs "0.8.x" tslib "^2.2.0" -"@stoplight/types@14.0.0", "@stoplight/types@^14.0.0": - version "14.0.0" - resolved "https://registry.yarnpkg.com/@stoplight/types/-/types-14.0.0.tgz#f444490664c2c16d5f06265fcbac8d94a33481e8" - integrity sha512-w7Ejau6TaB7RqR0vWzGJSdmgLEYD2frjgbHPZoxgGQwAq/R8Qh/D9p9Bl9JFdii+YTL5xoDjyX0c1WDRlbMV8g== +"@stoplight/types@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@stoplight/types/-/types-14.1.0.tgz#36b04488acc1d8ab5bb712416f50d3bc99610b34" + integrity sha512-fL8Nzw03+diALw91xHEHA5Q0WCGeW9WpPgZQjodNUWogAgJ56aJs03P9YzsQ1J6fT7/XjDqHMgn7/RlsBzB/SQ== dependencies: "@types/json-schema" "^7.0.4" utility-types "^3.10.0" @@ -4284,6 +4284,22 @@ "@types/json-schema" "^7.0.4" utility-types "^3.10.0" +"@stoplight/types@^14.0.0": + version "14.0.0" + resolved "https://registry.yarnpkg.com/@stoplight/types/-/types-14.0.0.tgz#f444490664c2c16d5f06265fcbac8d94a33481e8" + integrity sha512-w7Ejau6TaB7RqR0vWzGJSdmgLEYD2frjgbHPZoxgGQwAq/R8Qh/D9p9Bl9JFdii+YTL5xoDjyX0c1WDRlbMV8g== + dependencies: + "@types/json-schema" "^7.0.4" + utility-types "^3.10.0" + +"@stoplight/types@^14.1.1": + version "14.1.1" + resolved "https://registry.yarnpkg.com/@stoplight/types/-/types-14.1.1.tgz#0dd5761aac25673a951955e984c724c138368b7a" + integrity sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g== + dependencies: + "@types/json-schema" "^7.0.4" + utility-types "^3.10.0" + "@stoplight/yaml-ast-parser@0.0.48": version "0.0.48" resolved "https://registry.yarnpkg.com/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.48.tgz#442b21f419427acaa8a3106ebc5d73351c407002" @@ -21901,12 +21917,12 @@ tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1: +tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== -tslib@^2.4.0: +tslib@^2.4.0, tslib@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==