diff --git a/packages/elements-core/src/index.ts b/packages/elements-core/src/index.ts index 2db9515fb..3e7dd2e56 100644 --- a/packages/elements-core/src/index.ts +++ b/packages/elements-core/src/index.ts @@ -41,8 +41,8 @@ export { useRouter } from './hooks/useRouter'; export { Styled, withStyles } from './styled'; export { Divider, Group, ITableOfContentsTree, Item, ParsedNode, RoutingProps, TableOfContentItem } from './types'; export { isHttpOperation, isHttpService, isHttpWebhookOperation } from './utils/guards'; +export { resolveUrl } from './utils/http-spec/IServer'; export { ReferenceResolver } from './utils/ref-resolving/ReferenceResolver'; export { createResolvedObject } from './utils/ref-resolving/resolvedObject'; -export { slugify, resolveRelativeLink } from './utils/string'; +export { resolveRelativeLink, slugify } from './utils/string'; export { createElementClass } from './web-components/createElementClass'; -export { resolveUrl } from './utils/http-spec/IServer'; diff --git a/packages/elements/src/__fixtures__/api-descriptions/operationsSorter.ts b/packages/elements/src/__fixtures__/api-descriptions/operationsSorter.ts new file mode 100644 index 000000000..9a1e51a14 --- /dev/null +++ b/packages/elements/src/__fixtures__/api-descriptions/operationsSorter.ts @@ -0,0 +1,136 @@ +export const operationsSorter = { + openapi: '3.1.0', + info: { + title: 'Extended Sample API', + version: '1.0.0', + }, + paths: { + '/a-first': { + get: { + summary: 'Get the first item', + 'x-weight': 10, + responses: { + '200': { + description: 'Success', + }, + }, + }, + post: { + summary: 'Create the last item', + 'x-weight': 30, + responses: { + '201': { + description: 'Created', + }, + }, + }, + }, + '/m-middle': { + get: { + summary: 'Get the middle item', + 'x-weight': 20, + responses: { + '200': { + description: 'Success', + }, + }, + }, + }, + '/users': { + get: { + summary: 'List users', + tags: ['users'], + 'x-weight': 15, + responses: { + '200': { + description: 'User list', + }, + }, + }, + post: { + summary: 'Create a user', + tags: ['users'], + 'x-weight': 35, + responses: { + '201': { + description: 'User created', + }, + }, + }, + }, + '/users/{id}': { + get: { + summary: 'Get user by ID', + tags: ['users'], + 'x-weight': 25, + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'User details', + }, + }, + }, + }, + '/products': { + get: { + summary: 'List products', + tags: ['products'], + 'x-weight': 12, + responses: { + '200': { + description: 'Product list', + }, + }, + }, + post: { + summary: 'Create a product', + tags: ['products'], + 'x-weight': 32, + responses: { + '201': { + description: 'Product created', + }, + }, + }, + }, + '/products/{id}': { + delete: { + summary: 'Delete a product', + tags: ['products'], + 'x-weight': 28, + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { + type: 'string', + }, + }, + ], + responses: { + '204': { + description: 'Product deleted', + }, + }, + }, + }, + }, + tags: [ + { + name: 'products', + }, + { + name: 'users', + }, + ], +}; diff --git a/packages/elements/src/containers/API.stories.tsx b/packages/elements/src/containers/API.stories.tsx index 2874bc585..daec32918 100644 --- a/packages/elements/src/containers/API.stories.tsx +++ b/packages/elements/src/containers/API.stories.tsx @@ -3,6 +3,7 @@ import { Story } from '@storybook/react'; import * as React from 'react'; import { badgesForSchema } from '../__fixtures__/api-descriptions/badgesForSchema'; +import { operationsSorter } from '../__fixtures__/api-descriptions/operationsSorter'; import { simpleApiWithInternalOperations } from '../__fixtures__/api-descriptions/simpleApiWithInternalOperations'; import { simpleApiWithoutDescription } from '../__fixtures__/api-descriptions/simpleApiWithoutDescription'; import { todosApiBundled } from '../__fixtures__/api-descriptions/todosApiBundled'; @@ -115,3 +116,16 @@ WithExtensionRenderer.args = { apiDescriptionDocument: zoomApiYaml, }; WithExtensionRenderer.storyName = 'With Extension Renderer'; + +export const WithOperationsSorter = Template.bind({}); +const isNumber = (a: any): a is number => !isNaN(Number(a)); +WithOperationsSorter.args = { + operationsSorter: function (a, b) { + if (!isNumber(a.extensions?.['x-weight']) || !isNumber(b.extensions?.['x-weight'])) { + return 0; + } + return a.extensions!['x-weight'] - b.extensions!['x-weight']; + }, + apiDescriptionDocument: operationsSorter, +}; +WithOperationsSorter.storyName = 'With Operations Sorter'; diff --git a/packages/elements/src/containers/API.tsx b/packages/elements/src/containers/API.tsx index a5364e9ac..9b7deeac0 100644 --- a/packages/elements/src/containers/API.tsx +++ b/packages/elements/src/containers/API.tsx @@ -13,6 +13,7 @@ import { } from '@stoplight/elements-core'; import { ExtensionAddonRenderer } from '@stoplight/elements-core/components/Docs'; import { Box, Flex, Icon } from '@stoplight/mosaic'; +import type { IHttpOperation } from '@stoplight/types'; import { flow } from 'lodash'; import * as React from 'react'; import { useQuery } from 'react-query'; @@ -22,7 +23,7 @@ import { APIWithResponsiveSidebarLayout } from '../components/API/APIWithRespons import { APIWithSidebarLayout } from '../components/API/APIWithSidebarLayout'; import { APIWithStackedLayout } from '../components/API/APIWithStackedLayout'; import { useExportDocumentProps } from '../hooks/useExportDocumentProps'; -import { transformOasToServiceNode } from '../utils/oas'; +import { transformOasToServiceNode, transformServiceNode } from '../utils/oas'; export type APIProps = APIPropsWithDocument | APIPropsWithUrl; @@ -44,6 +45,8 @@ export type APIPropsWithDocument = { apiDescriptionUrl?: string; } & CommonAPIProps; +export type OperationsSorter = (a: IHttpOperation, b: IHttpOperation) => number; + export interface CommonAPIProps extends RoutingProps { /** * The API component supports two layout options. @@ -123,6 +126,13 @@ export interface CommonAPIProps extends RoutingProps { */ renderExtensionAddon?: ExtensionAddonRenderer; + /** + * Allows sorting operations by providing a custom sort function. When not provided, the order of operations + * is kept the same as they are defined in the OpenAPI document. + * @default undefined + */ + operationsSorter?: OperationsSorter; + outerRouter?: boolean; } @@ -147,6 +157,7 @@ export const APIImpl: React.FC = props => { tryItCorsProxy, maxRefDepth, renderExtensionAddon, + operationsSorter, basePath, outerRouter = false, } = props; @@ -171,7 +182,10 @@ export const APIImpl: React.FC = props => { const document = apiDescriptionDocument || fetchedDocument || ''; const parsedDocument = useParsedValue(document); const bundledDocument = useBundleRefsIntoDocument(parsedDocument, { baseUrl: apiDescriptionUrl }); - const serviceNode = React.useMemo(() => transformOasToServiceNode(bundledDocument), [bundledDocument]); + const serviceNode = React.useMemo( + () => transformServiceNode(transformOasToServiceNode(bundledDocument), { operationsSorter }), + [bundledDocument, operationsSorter], + ); const exportProps = useExportDocumentProps({ originalDocument: document, bundledDocument }); if (error) { diff --git a/packages/elements/src/utils/oas/__tests__/oas.spec.ts b/packages/elements/src/utils/oas/__tests__/oas.spec.ts index 865728632..5a2c45366 100644 --- a/packages/elements/src/utils/oas/__tests__/oas.spec.ts +++ b/packages/elements/src/utils/oas/__tests__/oas.spec.ts @@ -1,4 +1,7 @@ -import { transformOasToServiceNode } from '../'; +import { NodeType } from '@stoplight/types'; + +import { operationsSorter } from '../../../__fixtures__/api-descriptions/operationsSorter'; +import { transformOasToServiceNode, transformServiceNode } from '../'; const oas3Document = { 'x-stoplight': { id: 'abc' }, @@ -409,3 +412,35 @@ describe('computeOasNodes', () => { expect(serviceNode?.data.security).toHaveLength(1); }); }); + +describe('transformServiceNode', () => { + it('should return null for invalid document', () => { + expect(transformServiceNode(null, {})).toBeNull(); + }); + + it('should sort the operations with operationsSorter', () => { + const isNumber = (a: any): a is number => !isNaN(Number(a)); + const serviceNode = transformOasToServiceNode(operationsSorter); + const transformedServiceNode = transformServiceNode(serviceNode, { + operationsSorter: (a, b) => { + if (!isNumber(a.extensions?.['x-weight']) || !isNumber(b.extensions?.['x-weight'])) { + return 0; + } + return a.extensions!['x-weight'] - b.extensions!['x-weight']; + }, + }); + expect( + transformedServiceNode?.children.filter(n => n.type === NodeType.HttpOperation).map(n => n.uri), + ).toStrictEqual([ + '/paths/a-first/get', + '/paths/products/get', + '/paths/users/get', + '/paths/m-middle/get', + '/paths/users-id/get', + '/paths/products-id/delete', + '/paths/a-first/post', + '/paths/products/post', + '/paths/users/post', + ]); + }); +}); diff --git a/packages/elements/src/utils/oas/index.ts b/packages/elements/src/utils/oas/index.ts index 14e425c64..dba61de5f 100644 --- a/packages/elements/src/utils/oas/index.ts +++ b/packages/elements/src/utils/oas/index.ts @@ -42,6 +42,30 @@ const isOas31 = (parsed: unknown): parsed is OpenAPIObject => const OAS_MODEL_REGEXP = /((definitions|components)\/?(schemas)?)\//; +export type OperationsSorter = (a: IHttpOperation, b: IHttpOperation) => number; + +interface ServiceNodeTransformOptions { + operationsSorter?: OperationsSorter; +} + +export function transformServiceNode( + node: ServiceNode | null, + options: ServiceNodeTransformOptions, +): ServiceNode | null { + if (!node) return null; + const serviceNode = { ...node }; + if (options.operationsSorter) { + serviceNode.children = [...serviceNode.children]; + serviceNode.children.sort((a, b) => { + if (a.type === NodeType.HttpOperation && b.type === NodeType.HttpOperation) { + return options.operationsSorter!(a.data, b.data); + } + return 0; + }); + } + return serviceNode; +} + export function transformOasToServiceNode(apiDescriptionDocument: unknown) { if (isOas31(apiDescriptionDocument)) { return computeServiceNode( diff --git a/packages/elements/src/web-components/components.ts b/packages/elements/src/web-components/components.ts index f153812a8..6b86ca11e 100644 --- a/packages/elements/src/web-components/components.ts +++ b/packages/elements/src/web-components/components.ts @@ -22,5 +22,6 @@ export const ApiElement = createElementClass(API, { tryItCorsProxy: { type: 'string' }, maxRefDepth: { type: 'number' }, renderExtensionAddon: { type: 'function' }, + operationsSorter: { type: 'function' }, outerRouter: { type: 'boolean' }, });