Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/elements-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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',
},
],
};
14 changes: 14 additions & 0 deletions packages/elements/src/containers/API.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
18 changes: 16 additions & 2 deletions packages/elements/src/containers/API.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand All @@ -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.
Expand Down Expand Up @@ -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;
}

Expand All @@ -147,6 +157,7 @@ export const APIImpl: React.FC<APIProps> = props => {
tryItCorsProxy,
maxRefDepth,
renderExtensionAddon,
operationsSorter,
basePath,
outerRouter = false,
} = props;
Expand All @@ -171,7 +182,10 @@ export const APIImpl: React.FC<APIProps> = 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) {
Expand Down
37 changes: 36 additions & 1 deletion packages/elements/src/utils/oas/__tests__/oas.spec.ts
Original file line number Diff line number Diff line change
@@ -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' },
Expand Down Expand Up @@ -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',
]);
});
});
24 changes: 24 additions & 0 deletions packages/elements/src/utils/oas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/elements/src/web-components/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ export const ApiElement = createElementClass(API, {
tryItCorsProxy: { type: 'string' },
maxRefDepth: { type: 'number' },
renderExtensionAddon: { type: 'function' },
operationsSorter: { type: 'function' },
outerRouter: { type: 'boolean' },
});