From c3220a8c3d68fd56ecdbd5bb221921d27636a5d1 Mon Sep 17 00:00:00 2001 From: Val Gorodnichev Date: Mon, 9 Dec 2024 15:49:28 -0700 Subject: [PATCH 01/10] feat: moving utils code into it's own repo --- package.json | 8 +- packages/elements-core/package.json | 1 + .../src/components/TableOfContents/types.ts | 46 +- packages/elements-core/src/index.ts | 14 +- packages/elements-core/src/utils/guards.ts | 56 +- .../elements-core/src/utils/oas/security.ts | 83 +- .../src/utils/securitySchemes.ts | 121 +- packages/elements-core/src/utils/string.ts | 14 +- packages/elements-dev-portal/src/version.ts | 2 +- packages/elements-utils/LICENSE | 190 +++ packages/elements-utils/README.md | 7 + packages/elements-utils/jest.config.js | 10 + packages/elements-utils/package.json | 67 + packages/elements-utils/src/guards/index.ts | 49 + packages/elements-utils/src/index.ts | 18 + .../src}/oas/__tests__/oas.spec.ts | 0 packages/elements-utils/src/oas/index.ts | 191 +++ .../utils => elements-utils/src}/oas/oas2.ts | 0 .../utils => elements-utils/src}/oas/oas3.ts | 0 packages/elements-utils/src/oas/types.ts | 34 + .../oas/__tests__/security.spec.ts | 0 .../src/securitySchemes/oas/security.ts | 76 + .../src/securitySchemes/securitySchemes.ts | 117 ++ .../tableOfContents/__tests__/utils.test.ts | 1323 +++++++++++++++++ .../src/tableOfContents/types.ts | 51 + .../src/tableOfContents/utils.ts | 193 +++ packages/elements-utils/src/utils/string.ts | 11 + packages/elements-utils/tsconfig.build.json | 14 + packages/elements-utils/tsconfig.json | 7 + packages/elements/package.json | 1 + .../components/API/__tests__/utils.test.ts | 2 +- packages/elements/src/components/API/utils.ts | 196 +-- packages/elements/src/utils/oas/index.ts | 196 +-- packages/elements/src/utils/oas/types.ts | 46 +- 34 files changed, 2426 insertions(+), 718 deletions(-) create mode 100644 packages/elements-utils/LICENSE create mode 100644 packages/elements-utils/README.md create mode 100644 packages/elements-utils/jest.config.js create mode 100644 packages/elements-utils/package.json create mode 100644 packages/elements-utils/src/guards/index.ts create mode 100644 packages/elements-utils/src/index.ts rename packages/{elements/src/utils => elements-utils/src}/oas/__tests__/oas.spec.ts (100%) create mode 100644 packages/elements-utils/src/oas/index.ts rename packages/{elements/src/utils => elements-utils/src}/oas/oas2.ts (100%) rename packages/{elements/src/utils => elements-utils/src}/oas/oas3.ts (100%) create mode 100644 packages/elements-utils/src/oas/types.ts rename packages/{elements-core/src/utils => elements-utils/src/securitySchemes}/oas/__tests__/security.spec.ts (100%) create mode 100644 packages/elements-utils/src/securitySchemes/oas/security.ts create mode 100644 packages/elements-utils/src/securitySchemes/securitySchemes.ts create mode 100644 packages/elements-utils/src/tableOfContents/__tests__/utils.test.ts create mode 100644 packages/elements-utils/src/tableOfContents/types.ts create mode 100644 packages/elements-utils/src/tableOfContents/utils.ts create mode 100644 packages/elements-utils/src/utils/string.ts create mode 100644 packages/elements-utils/tsconfig.build.json create mode 100644 packages/elements-utils/tsconfig.json diff --git a/package.json b/package.json index a47076544..d22d34538 100644 --- a/package.json +++ b/package.json @@ -76,16 +76,18 @@ "elements": "yarn workspace @stoplight/elements", "elements-core": "yarn workspace @stoplight/elements-core", "elements-dev-portal": "yarn workspace @stoplight/elements-dev-portal", - "build": "yarn workspace @stoplight/elements-core build && yarn workspace @stoplight/elements build && yarn workspace @stoplight/elements-dev-portal build", + "elements-utils": "yarn workspace @stoplight/elements-utils", + "build": "yarn workspace @stoplight/elements-utils build && yarn workspace @stoplight/elements-core build && yarn workspace @stoplight/elements build && yarn workspace @stoplight/elements-dev-portal build", "build.docs": "yarn workspace @stoplight/elements build.docs", "postbuild": "yarn workspace @stoplight/elements test.packaging && yarn workspace @stoplight/elements-core test.packaging && yarn workspace @stoplight/elements-dev-portal test.packaging", "lint": "eslint '{packages,examples}/*/src/**/*.{ts,tsx}'", + "lint1": "eslint 'packages/elements-utils/src/**/*.{ts,tsx}'", "version": "lerna version --no-push", "release": "lerna publish from-package --yes --registry https://registry.npmjs.org --loglevel silly", "release.docs": "yarn workspace @stoplight/elements release.docs", "type-check": "yarn workspaces run type-check", - "test": "yarn workspace @stoplight/elements test && yarn workspace @stoplight/elements-core test && yarn workspace @stoplight/elements-dev-portal test --passWithNoTests", - "test.prod": "yarn workspace @stoplight/elements test.prod && yarn workspace @stoplight/elements-core test.prod && yarn workspace @stoplight/elements-dev-portal test.prod --passWithNoTests", + "test": "yarn workspace @stoplight/elements test && yarn workspace @stoplight/elements-core test && yarn workspace @stoplight/elements-dev-portal test --passWithNoTests && yarn workspace @stoplight/elements-utils test --passWithNoTests", + "test.prod": "yarn workspace @stoplight/elements test.prod && yarn workspace @stoplight/elements-core test.prod && yarn workspace @stoplight/elements-dev-portal test.prod --passWithNoTests && yarn workspace @stoplight/elements-utils test.prod --passWithNoTests", "prepublishOnly": "yarn build", "/// examples ///": "", "copy:angular": "mkdir examples-dev ; cp -a -v ./examples/angular ./examples-dev ; sh ./use-local-elements.sh angular", diff --git a/packages/elements-core/package.json b/packages/elements-core/package.json index 4f9a56010..8ffc1de33 100644 --- a/packages/elements-core/package.json +++ b/packages/elements-core/package.json @@ -57,6 +57,7 @@ ] }, "dependencies": { + "@stoplight/elements-utils": "^0.0.1", "@stoplight/http-spec": "^7.1.0", "@stoplight/json": "^3.21.0", "@stoplight/json-schema-ref-parser": "^9.2.7", diff --git a/packages/elements-core/src/components/TableOfContents/types.ts b/packages/elements-core/src/components/TableOfContents/types.ts index e3c69e15d..bee758e7d 100644 --- a/packages/elements-core/src/components/TableOfContents/types.ts +++ b/packages/elements-core/src/components/TableOfContents/types.ts @@ -1,3 +1,5 @@ +import type { TableOfContentsItem } from '@stoplight/elements-utils'; + export type TableOfContentsProps = { tree: TableOfContentsItem[]; activeId: string; @@ -14,38 +16,12 @@ export type CustomLinkComponent = React.ComponentType<{ children: React.ReactNode; }>; -export type TableOfContentsItem = TableOfContentsDivider | TableOfContentsGroupItem; - -export type TableOfContentsDivider = { - title: string; -}; - -export type TableOfContentsGroupItem = - | TableOfContentsGroup - | TableOfContentsNodeGroup - | TableOfContentsNode - | TableOfContentsExternalLink; - -export type TableOfContentsGroup = { - title: string; - items: TableOfContentsGroupItem[]; - itemsType?: 'article' | 'http_operation' | 'http_webhook' | 'model'; -}; - -export type TableOfContentsExternalLink = { - title: string; - url: string; -}; - -export type TableOfContentsNode< - T = 'http_service' | 'http_operation' | 'http_webhook' | 'model' | 'article' | 'overview', -> = { - id: string; - slug: string; - title: string; - type: T; - meta: string; - version?: string; -}; - -export type TableOfContentsNodeGroup = TableOfContentsNode<'http_service'> & TableOfContentsGroup; +export type { + TableOfContentsDivider, + TableOfContentsExternalLink, + TableOfContentsGroup, + TableOfContentsGroupItem, + TableOfContentsItem, + TableOfContentsNode, + TableOfContentsNodeGroup, +} from '@stoplight/elements-utils'; diff --git a/packages/elements-core/src/index.ts b/packages/elements-core/src/index.ts index fd1df3790..a6b41538e 100644 --- a/packages/elements-core/src/index.ts +++ b/packages/elements-core/src/index.ts @@ -14,13 +14,7 @@ export { ReactRouterMarkdownLink } from './components/MarkdownViewer/CustomCompo export { NonIdealState } from './components/NonIdealState'; export { PoweredByLink } from './components/PoweredByLink'; export { TableOfContents } from './components/TableOfContents'; -export { - CustomLinkComponent, - TableOfContentsGroup, - TableOfContentsItem, - TableOfContentsNode, - TableOfContentsNodeGroup, -} from './components/TableOfContents/types'; +export { CustomLinkComponent } 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'; @@ -44,3 +38,9 @@ export { ReferenceResolver } from './utils/ref-resolving/ReferenceResolver'; export { createResolvedObject } from './utils/ref-resolving/resolvedObject'; export { slugify } from './utils/string'; export { createElementClass } from './web-components/createElementClass'; +export type { + TableOfContentsGroup, + TableOfContentsItem, + TableOfContentsNode, + TableOfContentsNodeGroup, +} from '@stoplight/elements-utils'; diff --git a/packages/elements-core/src/utils/guards.ts b/packages/elements-core/src/utils/guards.ts index df1f88e8a..68711be93 100644 --- a/packages/elements-core/src/utils/guards.ts +++ b/packages/elements-core/src/utils/guards.ts @@ -1,48 +1,8 @@ -import type { IMarkdownViewerProps } from '@stoplight/markdown-viewer'; -import { isArray } from '@stoplight/mosaic'; -import { IHttpOperation, IHttpService, IHttpWebhookOperation, INode } from '@stoplight/types'; -import { JSONSchema7 } from 'json-schema'; -import { isObject, isPlainObject } from 'lodash'; - -export function isSMDASTRoot(maybeAst: unknown): maybeAst is IMarkdownViewerProps['markdown'] { - return ( - isObject(maybeAst) && - maybeAst['type' as keyof IMarkdownViewerProps['markdown']] === 'root' && - isArray(maybeAst['children' as keyof IMarkdownViewerProps['markdown']]) - ); -} - -export function isJSONSchema(maybeSchema: unknown): maybeSchema is JSONSchema7 { - // the trick is, JSONSchema doesn't define any required properties, so technically even an empty object is a valid JSONSchema - return isPlainObject(maybeSchema); -} - -function isStoplightNode(maybeNode: unknown): maybeNode is INode { - return isObject(maybeNode) && 'id' in maybeNode; -} - -export function isHttpService(maybeHttpService: unknown): maybeHttpService is IHttpService { - return isStoplightNode(maybeHttpService) && 'name' in maybeHttpService && 'version' in maybeHttpService; -} - -export function isHttpOperation(maybeHttpOperation: unknown): maybeHttpOperation is IHttpOperation { - 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]*))?)/, -); - -export function isProperUrl(url: string) { - return properUrl.test(url); -} +export { + isSMDASTRoot, + isJSONSchema, + isHttpService, + isHttpOperation, + isHttpWebhookOperation, + isProperUrl +} from '@stoplight/elements-utils'; \ No newline at end of file diff --git a/packages/elements-core/src/utils/oas/security.ts b/packages/elements-core/src/utils/oas/security.ts index 99352c161..4c023515c 100644 --- a/packages/elements-core/src/utils/oas/security.ts +++ b/packages/elements-core/src/utils/oas/security.ts @@ -1,76 +1,7 @@ -import { hash } from '@stoplight/http-spec/hash'; -import { - HttpSecurityScheme, - IOauth2AuthorizationCodeFlow, - IOauth2ClientCredentialsFlow, - IOauth2Flow, - IOauth2ImplicitFlow, - IOauth2PasswordFlow, -} from '@stoplight/types'; -import { capitalize, filter, flatten, isObject } from 'lodash'; - -export function getReadableSecurityName(securityScheme: HttpSecurityScheme, includeKey: boolean = false) { - let name = ''; - switch (securityScheme.type) { - case 'apiKey': - name = 'API Key'; - break; - case 'http': - name = `${capitalize(securityScheme.scheme)} Auth`; - break; - case 'oauth2': - name = 'OAuth 2.0'; - break; - case 'openIdConnect': - name = 'OpenID Connect'; - break; - case 'mutualTLS': - name = 'Mutual TLS'; - break; - case undefined: - name = 'None'; - break; - } - - return includeKey ? `${name} (${securityScheme.key})` : name; -} - -export function getReadableSecurityNames(securitySchemes: HttpSecurityScheme[], includeKey: boolean = false) { - if (securitySchemes.length === 0) return 'None'; - let name = ''; - for (let i = 0; i < securitySchemes.length; i++) { - if (i > 0) name += ' & '; - name += getReadableSecurityName(securitySchemes[i], shouldIncludeKey(securitySchemes, securitySchemes[i].type)); - } - - return includeKey ? `${name} (${securitySchemes[0].key})` : name; -} - -export function getServiceUriFromOperation(uri: string) { - const match = uri?.match(/(.*)\/(paths|operations)/); - return match && match.length > 1 ? match[1] || '/' : undefined; -} - -export const isOAuth2ImplicitFlow = (maybeFlow: IOauth2Flow): maybeFlow is IOauth2ImplicitFlow => - isObject(maybeFlow) && 'authorizationUrl' in maybeFlow && !('tokenUrl' in maybeFlow); - -export const isOauth2AuthorizationCodeFlow = (maybeFlow: IOauth2Flow): maybeFlow is IOauth2AuthorizationCodeFlow => - isObject(maybeFlow) && 'authorizationUrl' in maybeFlow && 'tokenUrl' in maybeFlow; - -export const isOauth2ClientCredentialsOrPasswordFlow = ( - maybeFlow: IOauth2Flow, -): maybeFlow is IOauth2ClientCredentialsFlow | IOauth2PasswordFlow => - isObject(maybeFlow) && !('authorizationUrl' in maybeFlow) && 'tokenUrl' in maybeFlow; - -export function shouldIncludeKey(schemes: HttpSecurityScheme[], type: HttpSecurityScheme['type']) { - return filter(schemes, { type }).length > 1; -} - -export const shouldAddKey = (auth: HttpSecurityScheme[], operationSecuritySchemes: HttpSecurityScheme[][]) => { - if (auth.length !== 1) return false; - return shouldIncludeKey(flatten(operationSecuritySchemes.filter(scheme => scheme.length === 1)), auth[0].type); -}; - -export const getSecurityGroupId = (id: string, position: number) => { - return hash(`http_security_group-${id}-${position}`); -}; +export { + getReadableSecurityName, + shouldIncludeKey, + getReadableSecurityNames, + shouldAddKey, + getSecurityGroupId +} from '@stoplight/elements-utils' \ No newline at end of file diff --git a/packages/elements-core/src/utils/securitySchemes.ts b/packages/elements-core/src/utils/securitySchemes.ts index e5de03a51..e1bca736a 100644 --- a/packages/elements-core/src/utils/securitySchemes.ts +++ b/packages/elements-core/src/utils/securitySchemes.ts @@ -1,117 +1,4 @@ -import { - HttpSecurityScheme, - IApiKeySecurityScheme, - IBasicSecurityScheme, - IBearerSecurityScheme, - IOauth2Flow, - IOauth2SecurityScheme, - IOauthFlowObjects, -} from '@stoplight/types'; -import { entries, keys } from 'lodash'; - -import { - isOauth2AuthorizationCodeFlow, - isOauth2ClientCredentialsOrPasswordFlow, - isOAuth2ImplicitFlow, -} from './oas/security'; - -const oauthFlowNames: Record = { - implicit: 'Implicit', - authorizationCode: 'Authorization Code', - clientCredentials: 'Client Credentials', - password: 'Password', -}; - -export function getDefaultDescription(scheme: HttpSecurityScheme) { - switch (scheme.type) { - case 'apiKey': - return getApiKeyDescription(scheme); - case 'http': - switch (scheme.scheme) { - case 'basic': - return getBasicAuthDescription(scheme); - case 'bearer': - return getBearerAuthDescription(scheme); - case 'digest': - return getDigestAuthDescription(scheme); - } - case 'oauth2': - return getOAuthDescription(scheme); - } - - return ''; -} - -export function getOptionalAuthDescription() { - return `Providing Auth is optional; requests may be made without an included Authorization header.`; -} - -function getApiKeyDescription(scheme: IApiKeySecurityScheme) { - const { in: inProperty, name } = scheme; - return `An API key is a token that you provide when making API calls. Include the token in a ${inProperty} parameter called \`${name}\`. - - Example: ${inProperty === 'query' ? `\`?${name}=123\`` : `\`${name}: 123\``}${getSecuritySchemeRoles(scheme)}`; -} - -function getBasicAuthDescription(schema: IBasicSecurityScheme) { - return `Basic authentication is a simple authentication scheme built into the HTTP protocol. - To use it, send your HTTP requests with an Authorization header that contains the word Basic - followed by a space and a base64-encoded string \`username:password\`. - - Example: \`Authorization: Basic ZGVtbzpwQDU1dzByZA==\`${getSecuritySchemeRoles(schema)}`; -} - -function getBearerAuthDescription(schema: IBearerSecurityScheme) { - return `Provide your bearer token in the Authorization header when making requests to protected resources. - - Example: \`Authorization: Bearer 123\`${getSecuritySchemeRoles(schema)}`; -} - -function getDigestAuthDescription(schema: IBasicSecurityScheme) { - return `Provide your encrypted digest scheme data in the Authorization header when making requests to protected resources. - - Example: \`Authorization: Digest username=guest, realm="test", nonce="2", uri="/uri", response="123"\`${getSecuritySchemeRoles( - schema, - )}`; -} - -function getOAuthDescription(scheme: IOauth2SecurityScheme) { - const flows = keys(scheme.flows); - return flows - .map(flow => - getOAuthFlowDescription( - oauthFlowNames[flow as keyof typeof oauthFlowNames], - scheme.flows[flow as keyof IOauthFlowObjects]!, - ), - ) - .join('\n\n'); -} - -function getOAuthFlowDescription(title: string, flow: IOauth2Flow) { - let description = `**${title} OAuth Flow**`; - - description += - isOAuth2ImplicitFlow(flow) || isOauth2AuthorizationCodeFlow(flow) - ? `\n\nAuthorize URL: ${flow.authorizationUrl}` - : ''; - - description += - isOauth2AuthorizationCodeFlow(flow) || isOauth2ClientCredentialsOrPasswordFlow(flow) - ? `\n\nToken URL: ${flow.tokenUrl}` - : ''; - - description += flow.refreshUrl ? `\n\nRefresh URL: ${flow.refreshUrl}` : ''; - - const scopes = entries(flow.scopes); - if (scopes.length) { - description += `\n\nScopes: -${scopes.map(([key, value]) => `- \`${key}\` - ${value}`).join('\n')}`; - } - - return description; -} - -function getSecuritySchemeRoles(scheme: HttpSecurityScheme) { - const scopes = scheme.extensions?.['x-scopes']; - return Array.isArray(scopes) ? `\n\nRoles: ${scopes.map(scope => `\`${scope}\``).join(', ')}` : ''; -} +export { + getDefaultDescription, + getOptionalAuthDescription +} from '@stoplight/elements-utils'; \ No newline at end of file diff --git a/packages/elements-core/src/utils/string.ts b/packages/elements-core/src/utils/string.ts index 64cf7c11a..26ea3e6d9 100644 --- a/packages/elements-core/src/utils/string.ts +++ b/packages/elements-core/src/utils/string.ts @@ -1,11 +1,5 @@ -import { curry } from 'lodash'; +export { + caseInsensitivelyEquals, + slugify +} from '@stoplight/elements-utils'; -export const caseInsensitivelyEquals = curry((a: string, b: string) => a.toUpperCase() === b.toUpperCase()); - -export function slugify(name: string) { - return name - .replace(/\/|{|}|\s/g, '-') - .replace(/-{2,}/, '-') - .replace(/^-/, '') - .replace(/-$/, ''); -} diff --git a/packages/elements-dev-portal/src/version.ts b/packages/elements-dev-portal/src/version.ts index c6b81bdba..ecb0c1b7a 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 = '2.4.2'; +export const appVersion = '2.5.2'; diff --git a/packages/elements-utils/LICENSE b/packages/elements-utils/LICENSE new file mode 100644 index 000000000..ba50e461e --- /dev/null +++ b/packages/elements-utils/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2018 Stoplight, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/elements-utils/README.md b/packages/elements-utils/README.md new file mode 100644 index 000000000..5354254f3 --- /dev/null +++ b/packages/elements-utils/README.md @@ -0,0 +1,7 @@ +# Elements utils + +## Code to extract table of contents from ACT generated by `http-spec` + +## COde to provide readable descriptions for security sections extracted from ACT generated by `http-spec` + + diff --git a/packages/elements-utils/jest.config.js b/packages/elements-utils/jest.config.js new file mode 100644 index 000000000..89bfabba0 --- /dev/null +++ b/packages/elements-utils/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + ...require('../../jest.config'), + reporters: [ + 'default', + [ + 'jest-junit', + { suiteName: 'elements-utils', outputFile: '/../../test-results/elements-utils/results.xml' }, + ], + ], +}; diff --git a/packages/elements-utils/package.json b/packages/elements-utils/package.json new file mode 100644 index 000000000..a0cf7aace --- /dev/null +++ b/packages/elements-utils/package.json @@ -0,0 +1,67 @@ +{ + "name": "@stoplight/elements-utils", + "version": "0.0.1", + "homepage": "https://github.com/stoplightio/elements", + "bugs": "https://github.com/stoplightio/elements/issues", + "author": "Stoplight ", + "repository": { + "type": "git", + "url": "https://github.com/stoplightio/elements" + }, + "license": "Apache-2.0", + "type": "commonjs", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "**/*" + ], + "engines": { + "node": ">=16" + }, + "scripts": { + "build": "sl-scripts build", + "commit": "git-cz", + "release": "sl-scripts release", + "release.docs": "sl-scripts release:docs", + "release.dryRun": "sl-scripts release --dry-run --debug", + "test": "jest", + "test.prod": "yarn test --coverage --maxWorkers=2", + "test.update": "yarn test --updateSnapshot", + "test.watch": "yarn test --watch", + "test.packaging": "node --input-type=commonjs -e \"require('./dist/index.js')\" && node --input-type=module -e \"import './dist/index.mjs'\"", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@stoplight/http-spec": "^7.1.0", + "@stoplight/json": "^3.21.0", + "@stoplight/types": "^14.1.1", + "json-schema": "^0.4.0", + "lodash": "^4.17.21", + "tslib": "^2.1.0" + }, + "devDependencies": { + "@stoplight/scripts": "10.0.0", + "@testing-library/dom": "^7.26.5", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/user-event": "^12.2.0", + "@types/enzyme": "3.x.x", + "@types/json-schema": "^7.0.11", + "@types/lodash": "^4.14.202", + "enzyme": "3.x.x", + "enzyme-to-json": "3.x.x", + "jest-fetch-mock": "^3.0.3", + "resolve-url-loader": "^5.0.0" + }, + "publishConfig": { + "directory": "dist" + }, + "release": { + "extends": "@stoplight/scripts/release" + } +} diff --git a/packages/elements-utils/src/guards/index.ts b/packages/elements-utils/src/guards/index.ts new file mode 100644 index 000000000..e4d918b3a --- /dev/null +++ b/packages/elements-utils/src/guards/index.ts @@ -0,0 +1,49 @@ +import type { IMarkdownViewerProps } from '@stoplight/markdown-viewer'; +import { IHttpOperation, IHttpService, IHttpWebhookOperation, INode } from '@stoplight/types'; +import { JSONSchema7 } from 'json-schema'; +import { isObject, isPlainObject } from 'lodash'; + +const isArray = (a: any) => Array.isArray(a); + +export function isSMDASTRoot(maybeAst: unknown): maybeAst is IMarkdownViewerProps['markdown'] { + return ( + isObject(maybeAst) && + maybeAst['type' as keyof IMarkdownViewerProps['markdown']] === 'root' && + isArray(maybeAst['children' as keyof IMarkdownViewerProps['markdown']]) + ); +} + +export function isJSONSchema(maybeSchema: unknown): maybeSchema is JSONSchema7 { + // the trick is, JSONSchema doesn't define any required properties, so technically even an empty object is a valid JSONSchema + return isPlainObject(maybeSchema); +} + +function isStoplightNode(maybeNode: unknown): maybeNode is INode { + return isObject(maybeNode) && 'id' in maybeNode; +} + +export function isHttpService(maybeHttpService: unknown): maybeHttpService is IHttpService { + return isStoplightNode(maybeHttpService) && 'name' in maybeHttpService && 'version' in maybeHttpService; +} + +export function isHttpOperation(maybeHttpOperation: unknown): maybeHttpOperation is IHttpOperation { + 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]*))?)/, +); + +export function isProperUrl(url: string) { + return properUrl.test(url); +} diff --git a/packages/elements-utils/src/index.ts b/packages/elements-utils/src/index.ts new file mode 100644 index 000000000..111909832 --- /dev/null +++ b/packages/elements-utils/src/index.ts @@ -0,0 +1,18 @@ +export * from './guards'; +export * from './oas'; +export type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from './oas/types'; +export * from './oas/types'; +export * from './securitySchemes/oas/security'; +export * from './securitySchemes/securitySchemes'; +export type { TagGroup } from './tableOfContents/utils'; +export * from './tableOfContents/utils'; +export type { + TableOfContentsDivider, + TableOfContentsExternalLink, + TableOfContentsGroup, + TableOfContentsGroupItem, + TableOfContentsItem, + TableOfContentsNode, + TableOfContentsNodeGroup, +} from './tableOfContents/types'; +export * from './utils/string'; diff --git a/packages/elements/src/utils/oas/__tests__/oas.spec.ts b/packages/elements-utils/src/oas/__tests__/oas.spec.ts similarity index 100% rename from packages/elements/src/utils/oas/__tests__/oas.spec.ts rename to packages/elements-utils/src/oas/__tests__/oas.spec.ts diff --git a/packages/elements-utils/src/oas/index.ts b/packages/elements-utils/src/oas/index.ts new file mode 100644 index 000000000..36b5882ba --- /dev/null +++ b/packages/elements-utils/src/oas/index.ts @@ -0,0 +1,191 @@ +import { + Oas2HttpOperationTransformer, + Oas2HttpServiceTransformer, + 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 { IHttpOperation, IHttpWebhookOperation, NodeType } from '@stoplight/types'; +import { get, isObject, last } from 'lodash'; +import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; +import { Spec } from 'swagger-schema-official'; + +import { slugify } from '../utils/string'; +import { oas2SourceMap } from './oas2'; +import { oas3SourceMap } from './oas3'; +import { ISourceNodeMap, NodeTypes, ServiceChildNode, ServiceNode } from './types'; +type OpenAPIObject = _OpenAPIObject & { + webhooks?: PathObject; +}; + +type SpecDocument = Spec | OpenAPIObject; + +const isOas2 = (parsed: unknown): parsed is Spec => + isObject(parsed) && + 'swagger' in parsed && + Number.parseInt(String((parsed as Partial<{ swagger: unknown }>).swagger)) === 2; + +const isOas3 = (parsed: unknown): parsed is OpenAPIObject => + isObject(parsed) && + 'openapi' in parsed && + Number.parseFloat(String((parsed as Partial<{ openapi: unknown }>).openapi)) >= 3; + +const isOas31 = (parsed: unknown): parsed is OpenAPIObject => + isObject(parsed) && + 'openapi' in parsed && + Number.parseFloat(String((parsed as Partial<{ openapi: unknown }>).openapi)) === 3.1; + +const OAS_MODEL_REGEXP = /((definitions|components)\/?(schemas)?)\//; + +export function transformOasToServiceNode(apiDescriptionDocument: unknown) { + if (isOas31(apiDescriptionDocument)) { + return computeServiceNode( + { ...apiDescriptionDocument, jsonSchemaDialect: 'http://json-schema.org/draft-07/schema#' }, + oas3SourceMap, + transformOas3Service, + transformOas3Operation, + ); + } + if (isOas3(apiDescriptionDocument)) { + return computeServiceNode(apiDescriptionDocument, oas3SourceMap, transformOas3Service, transformOas3Operation); + } else if (isOas2(apiDescriptionDocument)) { + return computeServiceNode(apiDescriptionDocument, oas2SourceMap, transformOas2Service, transformOas2Operation); + } + + return null; +} + +function computeServiceNode( + document: SpecDocument, + map: ISourceNodeMap[], + transformService: Oas2HttpServiceTransformer | Oas3HttpServiceTransformer, + transformOperation: Oas2HttpOperationTransformer | Oas3HttpEndpointOperationTransformer, +) { + const serviceDocument = transformService({ document }); + const serviceNode: ServiceNode = { + type: NodeType.HttpService, + uri: '/', + name: serviceDocument.name, + data: serviceDocument, + tags: serviceDocument.tags?.map(tag => tag.name) || [], + children: computeChildNodes(document, document, map, transformOperation), + }; + + return serviceNode; +} + +function computeChildNodes( + document: SpecDocument, + data: SpecDocument, + map: ISourceNodeMap[], + transformer: Oas2HttpOperationTransformer | Oas3HttpEndpointOperationTransformer, + parentUri: string = '', +) { + const nodes: ServiceChildNode[] = []; + + if (!isObject(data)) return nodes; + + for (const [key, value] of Object.entries(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, + name: path, + method, + config: OPERATION_CONFIG, + }) as IHttpOperation; + let parsedUri; + const encodedPath = String(encodePointerFragment(path)); + + if (operationDocument.iid) { + parsedUri = `/operations/${operationDocument.iid}`; + } else { + parsedUri = uri.replace(encodedPath, slugify(path)); + } + + nodes.push({ + type: NodeType.HttpOperation, + uri: parsedUri, + data: operationDocument, + 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/'); + + nodes.push({ + type: NodeType.Model, + uri: parsedUri, + data: schemaDocument, + name: schemaDocument.title || last(uri.split('/')) || '', + tags: schemaDocument['x-tags'] || [], + }); + } + + if (match.children) { + nodes.push(...computeChildNodes(document, value, match.children, transformer, uri)); + } + } + } + + return nodes; +} + +function findMapMatch(key: string | number, map: ISourceNodeMap[]): ISourceNodeMap | void { + if (typeof key === 'number') return; + for (const entry of map) { + const escapedKey = key.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); + + if (!!entry.match?.match(escapedKey) || (entry.notMatch !== void 0 && !entry.notMatch.match(escapedKey))) { + return entry; + } + } +} + +export function isJson(value: string) { + try { + JSON.parse(value); + } catch (e) { + return false; + } + return true; +} diff --git a/packages/elements/src/utils/oas/oas2.ts b/packages/elements-utils/src/oas/oas2.ts similarity index 100% rename from packages/elements/src/utils/oas/oas2.ts rename to packages/elements-utils/src/oas/oas2.ts diff --git a/packages/elements/src/utils/oas/oas3.ts b/packages/elements-utils/src/oas/oas3.ts similarity index 100% rename from packages/elements/src/utils/oas/oas3.ts rename to packages/elements-utils/src/oas/oas3.ts diff --git a/packages/elements-utils/src/oas/types.ts b/packages/elements-utils/src/oas/types.ts new file mode 100644 index 000000000..8d22cc546 --- /dev/null +++ b/packages/elements-utils/src/oas/types.ts @@ -0,0 +1,34 @@ +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', +} + +export interface ISourceNodeMap { + type: string; + match?: string; + notMatch?: string; + children?: ISourceNodeMap[]; +} + +type Node = { + type: T; + uri: string; + name: string; + data: D; + tags: string[]; +}; + +export type ServiceNode = Node & { children: ServiceChildNode[] }; +export type ServiceChildNode = OperationNode | WebhookNode | SchemaNode; +export type OperationNode = Node; +export type WebhookNode = Node; +export type SchemaNode = Node; diff --git a/packages/elements-core/src/utils/oas/__tests__/security.spec.ts b/packages/elements-utils/src/securitySchemes/oas/__tests__/security.spec.ts similarity index 100% rename from packages/elements-core/src/utils/oas/__tests__/security.spec.ts rename to packages/elements-utils/src/securitySchemes/oas/__tests__/security.spec.ts diff --git a/packages/elements-utils/src/securitySchemes/oas/security.ts b/packages/elements-utils/src/securitySchemes/oas/security.ts new file mode 100644 index 000000000..99352c161 --- /dev/null +++ b/packages/elements-utils/src/securitySchemes/oas/security.ts @@ -0,0 +1,76 @@ +import { hash } from '@stoplight/http-spec/hash'; +import { + HttpSecurityScheme, + IOauth2AuthorizationCodeFlow, + IOauth2ClientCredentialsFlow, + IOauth2Flow, + IOauth2ImplicitFlow, + IOauth2PasswordFlow, +} from '@stoplight/types'; +import { capitalize, filter, flatten, isObject } from 'lodash'; + +export function getReadableSecurityName(securityScheme: HttpSecurityScheme, includeKey: boolean = false) { + let name = ''; + switch (securityScheme.type) { + case 'apiKey': + name = 'API Key'; + break; + case 'http': + name = `${capitalize(securityScheme.scheme)} Auth`; + break; + case 'oauth2': + name = 'OAuth 2.0'; + break; + case 'openIdConnect': + name = 'OpenID Connect'; + break; + case 'mutualTLS': + name = 'Mutual TLS'; + break; + case undefined: + name = 'None'; + break; + } + + return includeKey ? `${name} (${securityScheme.key})` : name; +} + +export function getReadableSecurityNames(securitySchemes: HttpSecurityScheme[], includeKey: boolean = false) { + if (securitySchemes.length === 0) return 'None'; + let name = ''; + for (let i = 0; i < securitySchemes.length; i++) { + if (i > 0) name += ' & '; + name += getReadableSecurityName(securitySchemes[i], shouldIncludeKey(securitySchemes, securitySchemes[i].type)); + } + + return includeKey ? `${name} (${securitySchemes[0].key})` : name; +} + +export function getServiceUriFromOperation(uri: string) { + const match = uri?.match(/(.*)\/(paths|operations)/); + return match && match.length > 1 ? match[1] || '/' : undefined; +} + +export const isOAuth2ImplicitFlow = (maybeFlow: IOauth2Flow): maybeFlow is IOauth2ImplicitFlow => + isObject(maybeFlow) && 'authorizationUrl' in maybeFlow && !('tokenUrl' in maybeFlow); + +export const isOauth2AuthorizationCodeFlow = (maybeFlow: IOauth2Flow): maybeFlow is IOauth2AuthorizationCodeFlow => + isObject(maybeFlow) && 'authorizationUrl' in maybeFlow && 'tokenUrl' in maybeFlow; + +export const isOauth2ClientCredentialsOrPasswordFlow = ( + maybeFlow: IOauth2Flow, +): maybeFlow is IOauth2ClientCredentialsFlow | IOauth2PasswordFlow => + isObject(maybeFlow) && !('authorizationUrl' in maybeFlow) && 'tokenUrl' in maybeFlow; + +export function shouldIncludeKey(schemes: HttpSecurityScheme[], type: HttpSecurityScheme['type']) { + return filter(schemes, { type }).length > 1; +} + +export const shouldAddKey = (auth: HttpSecurityScheme[], operationSecuritySchemes: HttpSecurityScheme[][]) => { + if (auth.length !== 1) return false; + return shouldIncludeKey(flatten(operationSecuritySchemes.filter(scheme => scheme.length === 1)), auth[0].type); +}; + +export const getSecurityGroupId = (id: string, position: number) => { + return hash(`http_security_group-${id}-${position}`); +}; diff --git a/packages/elements-utils/src/securitySchemes/securitySchemes.ts b/packages/elements-utils/src/securitySchemes/securitySchemes.ts new file mode 100644 index 000000000..e5de03a51 --- /dev/null +++ b/packages/elements-utils/src/securitySchemes/securitySchemes.ts @@ -0,0 +1,117 @@ +import { + HttpSecurityScheme, + IApiKeySecurityScheme, + IBasicSecurityScheme, + IBearerSecurityScheme, + IOauth2Flow, + IOauth2SecurityScheme, + IOauthFlowObjects, +} from '@stoplight/types'; +import { entries, keys } from 'lodash'; + +import { + isOauth2AuthorizationCodeFlow, + isOauth2ClientCredentialsOrPasswordFlow, + isOAuth2ImplicitFlow, +} from './oas/security'; + +const oauthFlowNames: Record = { + implicit: 'Implicit', + authorizationCode: 'Authorization Code', + clientCredentials: 'Client Credentials', + password: 'Password', +}; + +export function getDefaultDescription(scheme: HttpSecurityScheme) { + switch (scheme.type) { + case 'apiKey': + return getApiKeyDescription(scheme); + case 'http': + switch (scheme.scheme) { + case 'basic': + return getBasicAuthDescription(scheme); + case 'bearer': + return getBearerAuthDescription(scheme); + case 'digest': + return getDigestAuthDescription(scheme); + } + case 'oauth2': + return getOAuthDescription(scheme); + } + + return ''; +} + +export function getOptionalAuthDescription() { + return `Providing Auth is optional; requests may be made without an included Authorization header.`; +} + +function getApiKeyDescription(scheme: IApiKeySecurityScheme) { + const { in: inProperty, name } = scheme; + return `An API key is a token that you provide when making API calls. Include the token in a ${inProperty} parameter called \`${name}\`. + + Example: ${inProperty === 'query' ? `\`?${name}=123\`` : `\`${name}: 123\``}${getSecuritySchemeRoles(scheme)}`; +} + +function getBasicAuthDescription(schema: IBasicSecurityScheme) { + return `Basic authentication is a simple authentication scheme built into the HTTP protocol. + To use it, send your HTTP requests with an Authorization header that contains the word Basic + followed by a space and a base64-encoded string \`username:password\`. + + Example: \`Authorization: Basic ZGVtbzpwQDU1dzByZA==\`${getSecuritySchemeRoles(schema)}`; +} + +function getBearerAuthDescription(schema: IBearerSecurityScheme) { + return `Provide your bearer token in the Authorization header when making requests to protected resources. + + Example: \`Authorization: Bearer 123\`${getSecuritySchemeRoles(schema)}`; +} + +function getDigestAuthDescription(schema: IBasicSecurityScheme) { + return `Provide your encrypted digest scheme data in the Authorization header when making requests to protected resources. + + Example: \`Authorization: Digest username=guest, realm="test", nonce="2", uri="/uri", response="123"\`${getSecuritySchemeRoles( + schema, + )}`; +} + +function getOAuthDescription(scheme: IOauth2SecurityScheme) { + const flows = keys(scheme.flows); + return flows + .map(flow => + getOAuthFlowDescription( + oauthFlowNames[flow as keyof typeof oauthFlowNames], + scheme.flows[flow as keyof IOauthFlowObjects]!, + ), + ) + .join('\n\n'); +} + +function getOAuthFlowDescription(title: string, flow: IOauth2Flow) { + let description = `**${title} OAuth Flow**`; + + description += + isOAuth2ImplicitFlow(flow) || isOauth2AuthorizationCodeFlow(flow) + ? `\n\nAuthorize URL: ${flow.authorizationUrl}` + : ''; + + description += + isOauth2AuthorizationCodeFlow(flow) || isOauth2ClientCredentialsOrPasswordFlow(flow) + ? `\n\nToken URL: ${flow.tokenUrl}` + : ''; + + description += flow.refreshUrl ? `\n\nRefresh URL: ${flow.refreshUrl}` : ''; + + const scopes = entries(flow.scopes); + if (scopes.length) { + description += `\n\nScopes: +${scopes.map(([key, value]) => `- \`${key}\` - ${value}`).join('\n')}`; + } + + return description; +} + +function getSecuritySchemeRoles(scheme: HttpSecurityScheme) { + const scopes = scheme.extensions?.['x-scopes']; + return Array.isArray(scopes) ? `\n\nRoles: ${scopes.map(scope => `\`${scope}\``).join(', ')}` : ''; +} diff --git a/packages/elements-utils/src/tableOfContents/__tests__/utils.test.ts b/packages/elements-utils/src/tableOfContents/__tests__/utils.test.ts new file mode 100644 index 000000000..9c338ff3c --- /dev/null +++ b/packages/elements-utils/src/tableOfContents/__tests__/utils.test.ts @@ -0,0 +1,1323 @@ +import { NodeType } from '@stoplight/types'; +import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; + +import { transformOasToServiceNode } from '../../oas'; +import { OperationNode, SchemaNode, WebhookNode } from '../../oas/types'; +import { computeAPITree, computeTagGroups } from '../utils'; + +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', + }, + { + name: 'alpha', + }, + ], + [pathProp]: { + '/a': { + get: { + tags: ['alpha'], + }, + }, + '/b': { + get: { + tags: ['beta'], + }, + }, + }, + }; + + 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'], + }, + ], + }, + { + 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('should support multiple tags by operations', () => { + 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', 'beta'], + }, + }, + '/b': { + get: { + tags: ['beta'], + }, + }, + }, + }; + + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + groups: [ + { + title: 'beta', + 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', + }, + { + id: '9695eccd3aa64', + name: 'beta', + }, + ], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/a', + tags: ['alpha', 'beta'], + }, + { + 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', + }, + { + id: '9695eccd3aa64', + name: 'beta', + }, + ], + security: [], + securityDeclarationType: 'inheritedFromService', + extensions: {}, + }, + name: '/a', + tags: ['alpha', 'beta'], + }, + ], + }, + ], + 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'], + }, + }, + }, + }; + + 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: {}, + }, + name: '/c', + tags: ['beta'], + }, + { + 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 methods", () => { + 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'], + }, + delete: { + tags: ['beta'], + }, + }, + }, + }; + + 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'], + }, + { + 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'], + }, + }, + }, + }; + + 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('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', + }, + { + name: 'alpha', + }, + ], + [pathProp]: { + '/a': { + get: { + tags: ['alpha'], + }, + }, + '/b': { + get: { + tags: ['beta'], + }, + }, + }, + }; + + 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'], + }, + ], + }, + { + 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' }, + }, + }, + }, + }, + }, + components: { + schemas: { + ImportantSchema: { + type: 'object', + properties: { + a: { type: 'string' }, + }, + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!)).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title, + }, + { + id: `/${pathProp}/something/get`, + meta: 'get', + slug: `/${pathProp}/something/get`, + title: '/something', + type: nodeType, + }, + { 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', + }, + [pathProp]: { + '/something': { + get: { + responses: { + 200: { + schema: { $ref: '#/definitions/schemas/ImportantSchema' }, + }, + }, + }, + }, + }, + components: { + schemas: { + ImportantSchema: { + type: 'object', + properties: { + a: { type: 'string' }, + }, + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideSchemas: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title, + }, + { + id: `/${pathProp}/something/get`, + meta: 'get', + slug: `/${pathProp}/something/get`, + title: '/something', + type: nodeType, + }, + ]); + }); + + 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, + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title, + }, + { + id: `/${pathProp}/something/get`, + meta: 'get', + slug: `/${pathProp}/something/get`, + title: '/something', + type: nodeType, + }, + ]); + }); + + 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', + }, + ], + [pathProp]: { + '/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, + }, + { + title: 'a', + itemsType: nodeType, + items: [ + { + id: `/${pathProp}/something/get`, + meta: 'get', + slug: `/${pathProp}/something/get`, + title: '/something', + type: nodeType, + }, + ], + }, + ]); + }); + + 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)!, { hideInternal: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + ]); + }); + + it('allows to hide nested internal models from ToC', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + tags: [ + { + name: 'a', + }, + ], + [pathProp]: {}, + components: { + schemas: { + a: { + 'x-tags': ['a'], + }, + b: { + 'x-tags': ['a'], + 'x-internal': true, + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { title: 'Schemas' }, + { + title: 'a', + itemsType: NodeType.Model, + items: [ + { + id: '/schemas/a', + slug: '/schemas/a', + title: 'a', + type: 'model', + meta: '', + }, + ], + }, + ]); + }); + + 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: [ + { + name: 'a', + }, + ], + [pathProp]: { + '/something': { + post: { + 'x-internal': true, + tags: ['a'], + }, + }, + '/something-else': { + post: { + tags: ['b'], + }, + }, + }, + components: { + schemas: { + a: { + 'x-tags': ['a'], + 'x-internal': true, + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title, + }, + { + title: 'b', + itemsType: nodeType, + items: [ + { + id: `/${pathProp}/something-else/post`, + meta: 'post', + slug: `/${pathProp}/something-else/post`, + title: '/something-else', + type: nodeType, + }, + ], + }, + ]); + }); + }); +}); + +describe('when grouping models', () => { + describe('computeTagGroups', () => { + it('orders models 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', + }, + { + name: 'alpha', + }, + ], + components: { + schemas: { + a: { + 'x-tags': ['alpha'], + }, + b: { + 'x-tags': ['beta'], + }, + }, + }, + }; + + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ + groups: [ + { + title: 'beta', + items: [ + { + type: NodeType.Model, + uri: '/schemas/b', + data: { + 'x-tags': ['beta'], + }, + name: 'b', + tags: ['beta'], + }, + ], + }, + { + title: 'alpha', + items: [ + { + type: NodeType.Model, + uri: '/schemas/a', + data: { + 'x-tags': ['alpha'], + }, + name: 'a', + tags: ['alpha'], + }, + ], + }, + ], + ungrouped: [], + }); + }); + + it("within the tags it doesn't reorder the schemas", () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + tags: [ + { + name: 'beta', + }, + { + name: 'alpha', + }, + ], + components: { + schemas: { + a: { + 'x-tags': ['alpha'], + }, + c: { + 'x-tags': ['beta'], + }, + b: { + 'x-tags': ['beta'], + }, + }, + }, + }; + + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ + groups: [ + { + title: 'beta', + items: [ + { + type: NodeType.Model, + uri: '/schemas/c', + data: { + 'x-tags': ['beta'], + }, + name: 'c', + tags: ['beta'], + }, + { + type: NodeType.Model, + uri: '/schemas/b', + data: { + 'x-tags': ['beta'], + }, + name: 'b', + tags: ['beta'], + }, + ], + }, + { + title: 'alpha', + items: [ + { + type: NodeType.Model, + uri: '/schemas/a', + data: { + 'x-tags': ['alpha'], + }, + name: 'a', + tags: ['alpha'], + }, + ], + }, + ], + 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', + }, + ], + components: { + schemas: { + a: { + 'x-tags': ['alpha'], + }, + b: { + 'x-tags': ['Beta'], + }, + }, + }, + }; + + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ + groups: [ + { + title: 'Beta', + items: [ + { + type: NodeType.Model, + uri: '/schemas/b', + data: { + 'x-tags': ['Beta'], + }, + name: 'b', + tags: ['Beta'], + }, + ], + }, + { + title: 'alpha', + items: [ + { + type: NodeType.Model, + uri: '/schemas/a', + data: { + 'x-tags': ['alpha'], + }, + 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: [ + { + name: 'Beta', + }, + { + name: 'alpha', + }, + ], + components: { + schemas: { + a: { + 'x-tags': ['alpha'], + }, + b: { + 'x-tags': ['beta'], + }, + }, + }, + }; + + const serviceNode = transformOasToServiceNode(apiDocument); + expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ + groups: [ + { + title: 'Beta', + items: [ + { + type: NodeType.Model, + uri: '/schemas/b', + data: { + 'x-tags': ['beta'], + }, + name: 'b', + tags: ['beta'], + }, + ], + }, + { + title: 'alpha', + items: [ + { + type: NodeType.Model, + uri: '/schemas/a', + data: { + 'x-tags': ['alpha'], + }, + name: 'a', + tags: ['alpha'], + }, + ], + }, + ], + ungrouped: [], + }); + }); + }); +}); diff --git a/packages/elements-utils/src/tableOfContents/types.ts b/packages/elements-utils/src/tableOfContents/types.ts new file mode 100644 index 000000000..e3c69e15d --- /dev/null +++ b/packages/elements-utils/src/tableOfContents/types.ts @@ -0,0 +1,51 @@ +export type TableOfContentsProps = { + tree: TableOfContentsItem[]; + activeId: string; + Link: CustomLinkComponent; + maxDepthOpenByDefault?: number; + externalScrollbar?: boolean; + isInResponsiveMode?: boolean; + onLinkClick?(): void; +}; + +export type CustomLinkComponent = React.ComponentType<{ + to: string; + className?: string; + children: React.ReactNode; +}>; + +export type TableOfContentsItem = TableOfContentsDivider | TableOfContentsGroupItem; + +export type TableOfContentsDivider = { + title: string; +}; + +export type TableOfContentsGroupItem = + | TableOfContentsGroup + | TableOfContentsNodeGroup + | TableOfContentsNode + | TableOfContentsExternalLink; + +export type TableOfContentsGroup = { + title: string; + items: TableOfContentsGroupItem[]; + itemsType?: 'article' | 'http_operation' | 'http_webhook' | 'model'; +}; + +export type TableOfContentsExternalLink = { + title: string; + url: string; +}; + +export type TableOfContentsNode< + T = 'http_service' | 'http_operation' | 'http_webhook' | 'model' | 'article' | 'overview', +> = { + id: string; + slug: string; + title: string; + type: T; + meta: string; + version?: string; +}; + +export type TableOfContentsNodeGroup = TableOfContentsNode<'http_service'> & TableOfContentsGroup; diff --git a/packages/elements-utils/src/tableOfContents/utils.ts b/packages/elements-utils/src/tableOfContents/utils.ts new file mode 100644 index 000000000..8be95ab0f --- /dev/null +++ b/packages/elements-utils/src/tableOfContents/utils.ts @@ -0,0 +1,193 @@ +// @ts-nocheck external file +import { NodeType } from '@stoplight/types'; +import { defaults } from 'lodash'; + +import { isHttpOperation, isHttpService, isHttpWebhookOperation } from '../guards'; +import type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '../oas/types'; +import type { TableOfContentsGroup, TableOfContentsItem } from '..tableOfContents/types'; + +type GroupableNode = OperationNode | WebhookNode | SchemaNode; + +type GroupableNode = OperationNode | WebhookNode | SchemaNode; + +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()); + + const groupableNodes = serviceNode.children.filter(n => n.type === nodeType) as T[]; + + for (const node of groupableNodes) { + for (const tagName of node.tags) { + const tagId = tagName.toLowerCase(); + if (groupsByTagId[tagId]) { + groupsByTagId[tagId].items.push(node); + } else { + const serviceTagIndex = lowerCaseServiceTags.findIndex(tn => tn === tagId); + const serviceTagName = serviceNode.tags[serviceTagIndex]; + groupsByTagId[tagId] = { + title: serviceTagName || tagName, + items: [node], + }; + } + } + if (node.tags.length === 0) { + ungrouped.push(node); + } + } + + const orderedTagGroups = Object.entries(groupsByTagId) + .sort(([g1], [g2]) => { + const g1LC = g1.toLowerCase(); + const g2LC = g2.toLowerCase(); + const g1Idx = lowerCaseServiceTags.findIndex(tn => tn === g1LC); + const g2Idx = lowerCaseServiceTags.findIndex(tn => tn === g2LC); + + // Move not-tagged groups to the bottom + if (g1Idx < 0 && g2Idx < 0) return 0; + if (g1Idx < 0) return 1; + if (g2Idx < 0) return -1; + + // sort tagged groups according to the order found in HttpService + return g1Idx - g2Idx; + }) + .map(([, tagGroup]) => tagGroup); + + return { groups: orderedTagGroups, ungrouped }; +} + +interface ComputeAPITreeConfig { + hideSchemas?: boolean; + hideInternal?: boolean; +} + +const defaultComputerAPITreeConfig = { + hideSchemas: false, + hideInternal: false, +}; + +export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeConfig = {}) => { + const mergedConfig = defaults(config, defaultComputerAPITreeConfig); + const tree: TableOfContentsItem[] = []; + + tree.push({ + id: '/', + slug: '/', + title: 'Overview', + type: 'overview', + meta: '', + }); + + const hasOperationNodes = serviceNode.children.some(node => node.type === NodeType.HttpOperation); + if (hasOperationNodes) { + tree.push({ + title: 'Endpoints', + }); + + const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpOperation); + addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpOperation, mergedConfig.hideInternal); + } + + const hasWebhookNodes = serviceNode.children.some(node => node.type === NodeType.HttpWebhook); + if (hasWebhookNodes) { + tree.push({ + title: 'Webhooks', + }); + + const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpWebhook); + addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpWebhook, mergedConfig.hideInternal); + } + + let schemaNodes = serviceNode.children.filter(node => node.type === NodeType.Model); + if (mergedConfig.hideInternal) { + schemaNodes = schemaNodes.filter(n => !isInternal(n)); + } + + if (!mergedConfig.hideSchemas && schemaNodes.length) { + tree.push({ + title: 'Schemas', + }); + + const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.Model); + addTagGroupsToTree(groups, ungrouped, tree, NodeType.Model, mergedConfig.hideInternal); + } + return tree; +}; + +export const findFirstNodeSlug = (tree: TableOfContentsItem[]): string | void => { + for (const item of tree) { + if ('slug' in item) { + return item.slug; + } + + if ('items' in item) { + const slug = findFirstNodeSlug(item.items); + if (slug) { + return slug; + } + } + } + + return; +}; + +export const isInternal = (node: ServiceChildNode | ServiceNode): boolean => { + const data = node.data; + + if (isHttpOperation(data) || isHttpWebhookOperation(data)) { + return !!data.internal; + } + + if (isHttpService(data)) { + return false; + } + + return !!data['x-internal' as keyof JSONSchema7]; +}; + +const addTagGroupsToTree = ( + groups: TagGroup[], + ungrouped: T[], + tree: TableOfContentsItem[], + itemsType: TableOfContentsGroup['itemsType'], + hideInternal: boolean, +) => { + // Show ungrouped nodes above tag groups + ungrouped.forEach(node => { + if (hideInternal && isInternal(node)) { + return; + } + tree.push({ + id: node.uri, + slug: node.uri, + title: node.name, + type: node.type, + meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', + }); + }); + + groups.forEach(group => { + const items = group.items.flatMap(node => { + if (hideInternal && isInternal(node)) { + return []; + } + return { + id: node.uri, + slug: node.uri, + title: node.name, + type: node.type, + meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', + }; + }); + if (items.length > 0) { + tree.push({ + title: group.title, + items, + itemsType, + }); + } + }); +}; diff --git a/packages/elements-utils/src/utils/string.ts b/packages/elements-utils/src/utils/string.ts new file mode 100644 index 000000000..64cf7c11a --- /dev/null +++ b/packages/elements-utils/src/utils/string.ts @@ -0,0 +1,11 @@ +import { curry } from 'lodash'; + +export const caseInsensitivelyEquals = curry((a: string, b: string) => a.toUpperCase() === b.toUpperCase()); + +export function slugify(name: string) { + return name + .replace(/\/|{|}|\s/g, '-') + .replace(/-{2,}/, '-') + .replace(/^-/, '') + .replace(/-$/, ''); +} diff --git a/packages/elements-utils/tsconfig.build.json b/packages/elements-utils/tsconfig.build.json new file mode 100644 index 000000000..52e2bd408 --- /dev/null +++ b/packages/elements-utils/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src" + ], + "exclude": [ + "**/__*__/**" + ], + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + "moduleResolution": "node" + } +} diff --git a/packages/elements-utils/tsconfig.json b/packages/elements-utils/tsconfig.json new file mode 100644 index 000000000..5388769dc --- /dev/null +++ b/packages/elements-utils/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src"], + + "exclude": ["**/__fixtures__/**", "**/__stories__/**", "**/__tests__/**"] +} diff --git a/packages/elements/package.json b/packages/elements/package.json index 61d4f7a54..0761781f6 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -63,6 +63,7 @@ ] }, "dependencies": { + "@stoplight/elements-utils": "^0.0.1", "@stoplight/elements-core": "^8.5.2", "@stoplight/http-spec": "^7.1.0", "@stoplight/json": "^3.18.1", diff --git a/packages/elements/src/components/API/__tests__/utils.test.ts b/packages/elements/src/components/API/__tests__/utils.test.ts index d32b67dfd..6b7753b76 100644 --- a/packages/elements/src/components/API/__tests__/utils.test.ts +++ b/packages/elements/src/components/API/__tests__/utils.test.ts @@ -3,7 +3,7 @@ import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; import { transformOasToServiceNode } from '../../../utils/oas'; import { OperationNode, SchemaNode, WebhookNode } from '../../../utils/oas/types'; -import { computeAPITree, computeTagGroups } from '../utils'; +import { computeTagGroups } from '../utils'; type OpenAPIObject = Partial<_OpenAPIObject> & { webhooks?: PathObject; diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index 1634cf5ff..3abe39fa9 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -1,196 +1,4 @@ -import { - isHttpOperation, - isHttpService, - isHttpWebhookOperation, - TableOfContentsGroup, - TableOfContentsItem, -} from '@stoplight/elements-core'; -import { NodeType } from '@stoplight/types'; -import { JSONSchema7 } from 'json-schema'; -import { defaults } from 'lodash'; -import { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; +export type { TagGroup } from '@stoplight/elements-utils'; +export { computeAPITree, computeTagGroups, findFirstNodeSlug, isInternal } from '@stoplight/elements-utils'; -type GroupableNode = OperationNode | WebhookNode | SchemaNode; - -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()); - - const groupableNodes = serviceNode.children.filter(n => n.type === nodeType) as T[]; - - for (const node of groupableNodes) { - for (const tagName of node.tags) { - const tagId = tagName.toLowerCase(); - if (groupsByTagId[tagId]) { - groupsByTagId[tagId].items.push(node); - } else { - const serviceTagIndex = lowerCaseServiceTags.findIndex(tn => tn === tagId); - const serviceTagName = serviceNode.tags[serviceTagIndex]; - groupsByTagId[tagId] = { - title: serviceTagName || tagName, - items: [node], - }; - } - } - if (node.tags.length === 0) { - ungrouped.push(node); - } - } - - const orderedTagGroups = Object.entries(groupsByTagId) - .sort(([g1], [g2]) => { - const g1LC = g1.toLowerCase(); - const g2LC = g2.toLowerCase(); - const g1Idx = lowerCaseServiceTags.findIndex(tn => tn === g1LC); - const g2Idx = lowerCaseServiceTags.findIndex(tn => tn === g2LC); - - // Move not-tagged groups to the bottom - if (g1Idx < 0 && g2Idx < 0) return 0; - if (g1Idx < 0) return 1; - if (g2Idx < 0) return -1; - - // sort tagged groups according to the order found in HttpService - return g1Idx - g2Idx; - }) - .map(([, tagGroup]) => tagGroup); - - return { groups: orderedTagGroups, ungrouped }; -} - -interface ComputeAPITreeConfig { - hideSchemas?: boolean; - hideInternal?: boolean; -} - -const defaultComputerAPITreeConfig = { - hideSchemas: false, - hideInternal: false, -}; - -export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeConfig = {}) => { - const mergedConfig = defaults(config, defaultComputerAPITreeConfig); - const tree: TableOfContentsItem[] = []; - - tree.push({ - id: '/', - slug: '/', - title: 'Overview', - type: 'overview', - meta: '', - }); - - const hasOperationNodes = serviceNode.children.some(node => node.type === NodeType.HttpOperation); - if (hasOperationNodes) { - tree.push({ - title: 'Endpoints', - }); - - const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpOperation); - addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpOperation, mergedConfig.hideInternal); - } - - const hasWebhookNodes = serviceNode.children.some(node => node.type === NodeType.HttpWebhook); - if (hasWebhookNodes) { - tree.push({ - title: 'Webhooks', - }); - - const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpWebhook); - addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpWebhook, mergedConfig.hideInternal); - } - - let schemaNodes = serviceNode.children.filter(node => node.type === NodeType.Model); - if (mergedConfig.hideInternal) { - schemaNodes = schemaNodes.filter(n => !isInternal(n)); - } - - if (!mergedConfig.hideSchemas && schemaNodes.length) { - tree.push({ - title: 'Schemas', - }); - - const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.Model); - addTagGroupsToTree(groups, ungrouped, tree, NodeType.Model, mergedConfig.hideInternal); - } - return tree; -}; - -export const findFirstNodeSlug = (tree: TableOfContentsItem[]): string | void => { - for (const item of tree) { - if ('slug' in item) { - return item.slug; - } - - if ('items' in item) { - const slug = findFirstNodeSlug(item.items); - if (slug) { - return slug; - } - } - } - - return; -}; - -export const isInternal = (node: ServiceChildNode | ServiceNode): boolean => { - const data = node.data; - - if (isHttpOperation(data) || isHttpWebhookOperation(data)) { - return !!data.internal; - } - - if (isHttpService(data)) { - return false; - } - - return !!data['x-internal' as keyof JSONSchema7]; -}; - -const addTagGroupsToTree = ( - groups: TagGroup[], - ungrouped: T[], - tree: TableOfContentsItem[], - itemsType: TableOfContentsGroup['itemsType'], - hideInternal: boolean, -) => { - // Show ungrouped nodes above tag groups - ungrouped.forEach(node => { - if (hideInternal && isInternal(node)) { - return; - } - tree.push({ - id: node.uri, - slug: node.uri, - title: node.name, - type: node.type, - meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', - }); - }); - - groups.forEach(group => { - const items = group.items.flatMap(node => { - if (hideInternal && isInternal(node)) { - return []; - } - return { - id: node.uri, - slug: node.uri, - title: node.name, - type: node.type, - meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', - }; - }); - if (items.length > 0) { - tree.push({ - title: group.title, - items, - itemsType, - }); - } - }); -}; diff --git a/packages/elements/src/utils/oas/index.ts b/packages/elements/src/utils/oas/index.ts index 14e425c64..0b7360fdb 100644 --- a/packages/elements/src/utils/oas/index.ts +++ b/packages/elements/src/utils/oas/index.ts @@ -1,192 +1,4 @@ -import { slugify } from '@stoplight/elements-core'; -import { - Oas2HttpOperationTransformer, - Oas2HttpServiceTransformer, - 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 { IHttpOperation, IHttpWebhookOperation, NodeType } from '@stoplight/types'; -import { get, isObject, last } from 'lodash'; -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; -}; - -type SpecDocument = Spec | OpenAPIObject; - -const isOas2 = (parsed: unknown): parsed is Spec => - isObject(parsed) && - 'swagger' in parsed && - Number.parseInt(String((parsed as Partial<{ swagger: unknown }>).swagger)) === 2; - -const isOas3 = (parsed: unknown): parsed is OpenAPIObject => - isObject(parsed) && - 'openapi' in parsed && - Number.parseFloat(String((parsed as Partial<{ openapi: unknown }>).openapi)) >= 3; - -const isOas31 = (parsed: unknown): parsed is OpenAPIObject => - isObject(parsed) && - 'openapi' in parsed && - Number.parseFloat(String((parsed as Partial<{ openapi: unknown }>).openapi)) === 3.1; - -const OAS_MODEL_REGEXP = /((definitions|components)\/?(schemas)?)\//; - -export function transformOasToServiceNode(apiDescriptionDocument: unknown) { - if (isOas31(apiDescriptionDocument)) { - return computeServiceNode( - { ...apiDescriptionDocument, jsonSchemaDialect: 'http://json-schema.org/draft-07/schema#' }, - oas3SourceMap, - transformOas3Service, - transformOas3Operation, - ); - } - if (isOas3(apiDescriptionDocument)) { - return computeServiceNode(apiDescriptionDocument, oas3SourceMap, transformOas3Service, transformOas3Operation); - } else if (isOas2(apiDescriptionDocument)) { - return computeServiceNode(apiDescriptionDocument, oas2SourceMap, transformOas2Service, transformOas2Operation); - } - - return null; -} - -function computeServiceNode( - document: SpecDocument, - map: ISourceNodeMap[], - transformService: Oas2HttpServiceTransformer | Oas3HttpServiceTransformer, - transformOperation: Oas2HttpOperationTransformer | Oas3HttpEndpointOperationTransformer, -) { - const serviceDocument = transformService({ document }); - const serviceNode: ServiceNode = { - type: NodeType.HttpService, - uri: '/', - name: serviceDocument.name, - data: serviceDocument, - tags: serviceDocument.tags?.map(tag => tag.name) || [], - children: computeChildNodes(document, document, map, transformOperation), - }; - - return serviceNode; -} - -function computeChildNodes( - document: SpecDocument, - data: SpecDocument, - map: ISourceNodeMap[], - transformer: Oas2HttpOperationTransformer | Oas3HttpEndpointOperationTransformer, - parentUri: string = '', -) { - const nodes: ServiceChildNode[] = []; - - if (!isObject(data)) return nodes; - - for (const [key, value] of Object.entries(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, - name: path, - method, - config: OPERATION_CONFIG, - }) as IHttpOperation; - let parsedUri; - const encodedPath = String(encodePointerFragment(path)); - - if (operationDocument.iid) { - parsedUri = `/operations/${operationDocument.iid}`; - } else { - parsedUri = uri.replace(encodedPath, slugify(path)); - } - - nodes.push({ - type: NodeType.HttpOperation, - uri: parsedUri, - data: operationDocument, - 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/'); - - nodes.push({ - type: NodeType.Model, - uri: parsedUri, - data: schemaDocument, - name: schemaDocument.title || last(uri.split('/')) || '', - tags: schemaDocument['x-tags'] || [], - }); - } - - if (match.children) { - nodes.push(...computeChildNodes(document, value, match.children, transformer, uri)); - } - } - } - - return nodes; -} - -function findMapMatch(key: string | number, map: ISourceNodeMap[]): ISourceNodeMap | void { - if (typeof key === 'number') return; - for (const entry of map) { - const escapedKey = key.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); - - if (!!entry.match?.match(escapedKey) || (entry.notMatch !== void 0 && !entry.notMatch.match(escapedKey))) { - return entry; - } - } -} - -export function isJson(value: string) { - try { - JSON.parse(value); - } catch (e) { - return false; - } - return true; -} +export { + isJson, + transformOasToServiceNode, +} from '@stoplight/elements-utils'; diff --git a/packages/elements/src/utils/oas/types.ts b/packages/elements/src/utils/oas/types.ts index 8d22cc546..902c7df91 100644 --- a/packages/elements/src/utils/oas/types.ts +++ b/packages/elements/src/utils/oas/types.ts @@ -1,34 +1,12 @@ -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', -} - -export interface ISourceNodeMap { - type: string; - match?: string; - notMatch?: string; - children?: ISourceNodeMap[]; -} - -type Node = { - type: T; - uri: string; - name: string; - data: D; - tags: string[]; -}; - -export type ServiceNode = Node & { children: ServiceChildNode[] }; -export type ServiceChildNode = OperationNode | WebhookNode | SchemaNode; -export type OperationNode = Node; -export type WebhookNode = Node; -export type SchemaNode = Node; +export { + NodeTypes, + ISourceNodeMap +} from '@stoplight/elements-utils'; + +export type { + ServiceNode, + ServiceChildNode, + OperationNode, + WebhookNode, + SchemaNode +} from '@stoplight/elements-utils'; \ No newline at end of file From ab81a6388cea1da7691d7be6a2277cbd0f4db2e1 Mon Sep 17 00:00:00 2001 From: Val Gorodnichev Date: Mon, 9 Dec 2024 16:17:26 -0700 Subject: [PATCH 02/10] fix: keep the folder structure --- .../components/Docs}/types.ts | 0 .../utils/guards.ts} | 0 .../utils}/oas/__tests__/security.spec.ts | 0 .../utils}/oas/security.ts | 0 .../utils}/securitySchemes.ts | 0 .../src/{ => elements-core}/utils/string.ts | 0 .../components/API/__tests__/utils.test.ts | 2 +- .../components/API}/utils.ts | 4 +- .../utils}/oas/__tests__/oas.spec.ts | 0 .../src/{ => elements/utils}/oas/index.ts | 2 +- .../src/{ => elements/utils}/oas/oas2.ts | 0 .../src/{ => elements/utils}/oas/oas3.ts | 0 .../src/{ => elements/utils}/oas/types.ts | 0 packages/elements-utils/src/index.ts | 20 +- .../tableOfContents/__tests__/utils.test.ts | 1323 ----------------- 15 files changed, 14 insertions(+), 1337 deletions(-) rename packages/elements-utils/src/{tableOfContents => elements-core/components/Docs}/types.ts (100%) rename packages/elements-utils/src/{guards/index.ts => elements-core/utils/guards.ts} (100%) rename packages/elements-utils/src/{securitySchemes => elements-core/utils}/oas/__tests__/security.spec.ts (100%) rename packages/elements-utils/src/{securitySchemes => elements-core/utils}/oas/security.ts (100%) rename packages/elements-utils/src/{securitySchemes => elements-core/utils}/securitySchemes.ts (100%) rename packages/elements-utils/src/{ => elements-core}/utils/string.ts (100%) rename packages/{elements/src => elements-utils/src/elements}/components/API/__tests__/utils.test.ts (99%) rename packages/elements-utils/src/{tableOfContents => elements/components/API}/utils.ts (98%) rename packages/elements-utils/src/{ => elements/utils}/oas/__tests__/oas.spec.ts (100%) rename packages/elements-utils/src/{ => elements/utils}/oas/index.ts (99%) rename packages/elements-utils/src/{ => elements/utils}/oas/oas2.ts (100%) rename packages/elements-utils/src/{ => elements/utils}/oas/oas3.ts (100%) rename packages/elements-utils/src/{ => elements/utils}/oas/types.ts (100%) delete mode 100644 packages/elements-utils/src/tableOfContents/__tests__/utils.test.ts diff --git a/packages/elements-utils/src/tableOfContents/types.ts b/packages/elements-utils/src/elements-core/components/Docs/types.ts similarity index 100% rename from packages/elements-utils/src/tableOfContents/types.ts rename to packages/elements-utils/src/elements-core/components/Docs/types.ts diff --git a/packages/elements-utils/src/guards/index.ts b/packages/elements-utils/src/elements-core/utils/guards.ts similarity index 100% rename from packages/elements-utils/src/guards/index.ts rename to packages/elements-utils/src/elements-core/utils/guards.ts diff --git a/packages/elements-utils/src/securitySchemes/oas/__tests__/security.spec.ts b/packages/elements-utils/src/elements-core/utils/oas/__tests__/security.spec.ts similarity index 100% rename from packages/elements-utils/src/securitySchemes/oas/__tests__/security.spec.ts rename to packages/elements-utils/src/elements-core/utils/oas/__tests__/security.spec.ts diff --git a/packages/elements-utils/src/securitySchemes/oas/security.ts b/packages/elements-utils/src/elements-core/utils/oas/security.ts similarity index 100% rename from packages/elements-utils/src/securitySchemes/oas/security.ts rename to packages/elements-utils/src/elements-core/utils/oas/security.ts diff --git a/packages/elements-utils/src/securitySchemes/securitySchemes.ts b/packages/elements-utils/src/elements-core/utils/securitySchemes.ts similarity index 100% rename from packages/elements-utils/src/securitySchemes/securitySchemes.ts rename to packages/elements-utils/src/elements-core/utils/securitySchemes.ts diff --git a/packages/elements-utils/src/utils/string.ts b/packages/elements-utils/src/elements-core/utils/string.ts similarity index 100% rename from packages/elements-utils/src/utils/string.ts rename to packages/elements-utils/src/elements-core/utils/string.ts diff --git a/packages/elements/src/components/API/__tests__/utils.test.ts b/packages/elements-utils/src/elements/components/API/__tests__/utils.test.ts similarity index 99% rename from packages/elements/src/components/API/__tests__/utils.test.ts rename to packages/elements-utils/src/elements/components/API/__tests__/utils.test.ts index 6b7753b76..d32b67dfd 100644 --- a/packages/elements/src/components/API/__tests__/utils.test.ts +++ b/packages/elements-utils/src/elements/components/API/__tests__/utils.test.ts @@ -3,7 +3,7 @@ import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; import { transformOasToServiceNode } from '../../../utils/oas'; import { OperationNode, SchemaNode, WebhookNode } from '../../../utils/oas/types'; -import { computeTagGroups } from '../utils'; +import { computeAPITree, computeTagGroups } from '../utils'; type OpenAPIObject = Partial<_OpenAPIObject> & { webhooks?: PathObject; diff --git a/packages/elements-utils/src/tableOfContents/utils.ts b/packages/elements-utils/src/elements/components/API/utils.ts similarity index 98% rename from packages/elements-utils/src/tableOfContents/utils.ts rename to packages/elements-utils/src/elements/components/API/utils.ts index 8be95ab0f..1c0eecd10 100644 --- a/packages/elements-utils/src/tableOfContents/utils.ts +++ b/packages/elements-utils/src/elements/components/API/utils.ts @@ -2,8 +2,8 @@ import { NodeType } from '@stoplight/types'; import { defaults } from 'lodash'; -import { isHttpOperation, isHttpService, isHttpWebhookOperation } from '../guards'; -import type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '../oas/types'; +import { isHttpOperation, isHttpService, isHttpWebhookOperation } from '../../../elements-core/utils/guards'; +import type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '../../../oas/types'; import type { TableOfContentsGroup, TableOfContentsItem } from '..tableOfContents/types'; type GroupableNode = OperationNode | WebhookNode | SchemaNode; diff --git a/packages/elements-utils/src/oas/__tests__/oas.spec.ts b/packages/elements-utils/src/elements/utils/oas/__tests__/oas.spec.ts similarity index 100% rename from packages/elements-utils/src/oas/__tests__/oas.spec.ts rename to packages/elements-utils/src/elements/utils/oas/__tests__/oas.spec.ts diff --git a/packages/elements-utils/src/oas/index.ts b/packages/elements-utils/src/elements/utils/oas/index.ts similarity index 99% rename from packages/elements-utils/src/oas/index.ts rename to packages/elements-utils/src/elements/utils/oas/index.ts index 36b5882ba..bb4d1549d 100644 --- a/packages/elements-utils/src/oas/index.ts +++ b/packages/elements-utils/src/elements/utils/oas/index.ts @@ -14,7 +14,7 @@ import { get, isObject, last } from 'lodash'; import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; import { Spec } from 'swagger-schema-official'; -import { slugify } from '../utils/string'; +import { slugify } from '../../../elements-core/utils/string' import { oas2SourceMap } from './oas2'; import { oas3SourceMap } from './oas3'; import { ISourceNodeMap, NodeTypes, ServiceChildNode, ServiceNode } from './types'; diff --git a/packages/elements-utils/src/oas/oas2.ts b/packages/elements-utils/src/elements/utils/oas/oas2.ts similarity index 100% rename from packages/elements-utils/src/oas/oas2.ts rename to packages/elements-utils/src/elements/utils/oas/oas2.ts diff --git a/packages/elements-utils/src/oas/oas3.ts b/packages/elements-utils/src/elements/utils/oas/oas3.ts similarity index 100% rename from packages/elements-utils/src/oas/oas3.ts rename to packages/elements-utils/src/elements/utils/oas/oas3.ts diff --git a/packages/elements-utils/src/oas/types.ts b/packages/elements-utils/src/elements/utils/oas/types.ts similarity index 100% rename from packages/elements-utils/src/oas/types.ts rename to packages/elements-utils/src/elements/utils/oas/types.ts diff --git a/packages/elements-utils/src/index.ts b/packages/elements-utils/src/index.ts index 111909832..4da95bb95 100644 --- a/packages/elements-utils/src/index.ts +++ b/packages/elements-utils/src/index.ts @@ -1,11 +1,11 @@ -export * from './guards'; -export * from './oas'; -export type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from './oas/types'; -export * from './oas/types'; -export * from './securitySchemes/oas/security'; -export * from './securitySchemes/securitySchemes'; -export type { TagGroup } from './tableOfContents/utils'; -export * from './tableOfContents/utils'; +export * from './elements-core/utils/guards'; +export * from './elements/utils/oas'; +export type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from './elements/utils/oas/types'; +export * from './elements/utils/oas/types'; +export * from '././elements-core/utils/oas/security'; +export * from './elements-core/utils/oas/security'; +export type { TagGroup } from './elements/components/API/utils'; +export * from './elements/components/API/utils'; export type { TableOfContentsDivider, TableOfContentsExternalLink, @@ -14,5 +14,5 @@ export type { TableOfContentsItem, TableOfContentsNode, TableOfContentsNodeGroup, -} from './tableOfContents/types'; -export * from './utils/string'; +} from './elements-core/components/Docs/types'; +export * from './elements-core/utils/string'; diff --git a/packages/elements-utils/src/tableOfContents/__tests__/utils.test.ts b/packages/elements-utils/src/tableOfContents/__tests__/utils.test.ts deleted file mode 100644 index 9c338ff3c..000000000 --- a/packages/elements-utils/src/tableOfContents/__tests__/utils.test.ts +++ /dev/null @@ -1,1323 +0,0 @@ -import { NodeType } from '@stoplight/types'; -import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; - -import { transformOasToServiceNode } from '../../oas'; -import { OperationNode, SchemaNode, WebhookNode } from '../../oas/types'; -import { computeAPITree, computeTagGroups } from '../utils'; - -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', - }, - { - name: 'alpha', - }, - ], - [pathProp]: { - '/a': { - get: { - tags: ['alpha'], - }, - }, - '/b': { - get: { - tags: ['beta'], - }, - }, - }, - }; - - 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'], - }, - ], - }, - { - 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('should support multiple tags by operations', () => { - 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', 'beta'], - }, - }, - '/b': { - get: { - tags: ['beta'], - }, - }, - }, - }; - - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ - groups: [ - { - title: 'beta', - 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', - }, - { - id: '9695eccd3aa64', - name: 'beta', - }, - ], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, - }, - name: '/a', - tags: ['alpha', 'beta'], - }, - { - 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', - }, - { - id: '9695eccd3aa64', - name: 'beta', - }, - ], - security: [], - securityDeclarationType: 'inheritedFromService', - extensions: {}, - }, - name: '/a', - tags: ['alpha', 'beta'], - }, - ], - }, - ], - 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'], - }, - }, - }, - }; - - 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: {}, - }, - name: '/c', - tags: ['beta'], - }, - { - 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 methods", () => { - 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'], - }, - delete: { - tags: ['beta'], - }, - }, - }, - }; - - 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'], - }, - { - 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'], - }, - }, - }, - }; - - 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('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', - }, - { - name: 'alpha', - }, - ], - [pathProp]: { - '/a': { - get: { - tags: ['alpha'], - }, - }, - '/b': { - get: { - tags: ['beta'], - }, - }, - }, - }; - - 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'], - }, - ], - }, - { - 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' }, - }, - }, - }, - }, - }, - components: { - schemas: { - ImportantSchema: { - type: 'object', - properties: { - a: { type: 'string' }, - }, - }, - }, - }, - }; - - expect(computeAPITree(transformOasToServiceNode(apiDocument)!)).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - { - title, - }, - { - id: `/${pathProp}/something/get`, - meta: 'get', - slug: `/${pathProp}/something/get`, - title: '/something', - type: nodeType, - }, - { 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', - }, - [pathProp]: { - '/something': { - get: { - responses: { - 200: { - schema: { $ref: '#/definitions/schemas/ImportantSchema' }, - }, - }, - }, - }, - }, - components: { - schemas: { - ImportantSchema: { - type: 'object', - properties: { - a: { type: 'string' }, - }, - }, - }, - }, - }; - - expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideSchemas: true })).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - { - title, - }, - { - id: `/${pathProp}/something/get`, - meta: 'get', - slug: `/${pathProp}/something/get`, - title: '/something', - type: nodeType, - }, - ]); - }); - - 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, - }, - }, - }, - }; - - expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - { - title, - }, - { - id: `/${pathProp}/something/get`, - meta: 'get', - slug: `/${pathProp}/something/get`, - title: '/something', - type: nodeType, - }, - ]); - }); - - 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', - }, - ], - [pathProp]: { - '/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, - }, - { - title: 'a', - itemsType: nodeType, - items: [ - { - id: `/${pathProp}/something/get`, - meta: 'get', - slug: `/${pathProp}/something/get`, - title: '/something', - type: nodeType, - }, - ], - }, - ]); - }); - - 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)!, { hideInternal: true })).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - ]); - }); - - it('allows to hide nested internal models from ToC', () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - tags: [ - { - name: 'a', - }, - ], - [pathProp]: {}, - components: { - schemas: { - a: { - 'x-tags': ['a'], - }, - b: { - 'x-tags': ['a'], - 'x-internal': true, - }, - }, - }, - }; - - expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - { title: 'Schemas' }, - { - title: 'a', - itemsType: NodeType.Model, - items: [ - { - id: '/schemas/a', - slug: '/schemas/a', - title: 'a', - type: 'model', - meta: '', - }, - ], - }, - ]); - }); - - 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: [ - { - name: 'a', - }, - ], - [pathProp]: { - '/something': { - post: { - 'x-internal': true, - tags: ['a'], - }, - }, - '/something-else': { - post: { - tags: ['b'], - }, - }, - }, - components: { - schemas: { - a: { - 'x-tags': ['a'], - 'x-internal': true, - }, - }, - }, - }; - - expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ - { - id: '/', - meta: '', - slug: '/', - title: 'Overview', - type: 'overview', - }, - { - title, - }, - { - title: 'b', - itemsType: nodeType, - items: [ - { - id: `/${pathProp}/something-else/post`, - meta: 'post', - slug: `/${pathProp}/something-else/post`, - title: '/something-else', - type: nodeType, - }, - ], - }, - ]); - }); - }); -}); - -describe('when grouping models', () => { - describe('computeTagGroups', () => { - it('orders models 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', - }, - { - name: 'alpha', - }, - ], - components: { - schemas: { - a: { - 'x-tags': ['alpha'], - }, - b: { - 'x-tags': ['beta'], - }, - }, - }, - }; - - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ - groups: [ - { - title: 'beta', - items: [ - { - type: NodeType.Model, - uri: '/schemas/b', - data: { - 'x-tags': ['beta'], - }, - name: 'b', - tags: ['beta'], - }, - ], - }, - { - title: 'alpha', - items: [ - { - type: NodeType.Model, - uri: '/schemas/a', - data: { - 'x-tags': ['alpha'], - }, - name: 'a', - tags: ['alpha'], - }, - ], - }, - ], - ungrouped: [], - }); - }); - - it("within the tags it doesn't reorder the schemas", () => { - const apiDocument: OpenAPIObject = { - openapi: '3.0.0', - info: { - title: 'some api', - version: '1.0.0', - description: 'some description', - }, - tags: [ - { - name: 'beta', - }, - { - name: 'alpha', - }, - ], - components: { - schemas: { - a: { - 'x-tags': ['alpha'], - }, - c: { - 'x-tags': ['beta'], - }, - b: { - 'x-tags': ['beta'], - }, - }, - }, - }; - - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ - groups: [ - { - title: 'beta', - items: [ - { - type: NodeType.Model, - uri: '/schemas/c', - data: { - 'x-tags': ['beta'], - }, - name: 'c', - tags: ['beta'], - }, - { - type: NodeType.Model, - uri: '/schemas/b', - data: { - 'x-tags': ['beta'], - }, - name: 'b', - tags: ['beta'], - }, - ], - }, - { - title: 'alpha', - items: [ - { - type: NodeType.Model, - uri: '/schemas/a', - data: { - 'x-tags': ['alpha'], - }, - name: 'a', - tags: ['alpha'], - }, - ], - }, - ], - 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', - }, - ], - components: { - schemas: { - a: { - 'x-tags': ['alpha'], - }, - b: { - 'x-tags': ['Beta'], - }, - }, - }, - }; - - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ - groups: [ - { - title: 'Beta', - items: [ - { - type: NodeType.Model, - uri: '/schemas/b', - data: { - 'x-tags': ['Beta'], - }, - name: 'b', - tags: ['Beta'], - }, - ], - }, - { - title: 'alpha', - items: [ - { - type: NodeType.Model, - uri: '/schemas/a', - data: { - 'x-tags': ['alpha'], - }, - 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: [ - { - name: 'Beta', - }, - { - name: 'alpha', - }, - ], - components: { - schemas: { - a: { - 'x-tags': ['alpha'], - }, - b: { - 'x-tags': ['beta'], - }, - }, - }, - }; - - const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ - groups: [ - { - title: 'Beta', - items: [ - { - type: NodeType.Model, - uri: '/schemas/b', - data: { - 'x-tags': ['beta'], - }, - name: 'b', - tags: ['beta'], - }, - ], - }, - { - title: 'alpha', - items: [ - { - type: NodeType.Model, - uri: '/schemas/a', - data: { - 'x-tags': ['alpha'], - }, - name: 'a', - tags: ['alpha'], - }, - ], - }, - ], - ungrouped: [], - }); - }); - }); -}); From 8f400fa98ac6baa09471e3cd5cd1c5793b8e0312 Mon Sep 17 00:00:00 2001 From: Val Gorodnichev Date: Tue, 10 Dec 2024 07:28:36 -0700 Subject: [PATCH 03/10] fix: lint --- packages/elements-core/src/utils/guards.ts | 10 +++++----- packages/elements-core/src/utils/oas/security.ts | 6 +++--- .../elements-core/src/utils/securitySchemes.ts | 5 +---- packages/elements-core/src/utils/string.ts | 6 +----- .../elements-utils/src/elements/utils/oas/index.ts | 3 ++- packages/elements-utils/src/index.ts | 10 +++++----- packages/elements-utils/tsconfig.build.json | 1 - packages/elements-utils/tsconfig.json | 6 +++++- packages/elements/src/components/API/utils.ts | 2 -- packages/elements/src/utils/oas/index.ts | 5 +---- packages/elements/src/utils/oas/types.ts | 14 ++------------ 11 files changed, 25 insertions(+), 43 deletions(-) diff --git a/packages/elements-core/src/utils/guards.ts b/packages/elements-core/src/utils/guards.ts index 68711be93..66d9e9d5c 100644 --- a/packages/elements-core/src/utils/guards.ts +++ b/packages/elements-core/src/utils/guards.ts @@ -1,8 +1,8 @@ export { - isSMDASTRoot, - isJSONSchema, - isHttpService, isHttpOperation, + isHttpService, isHttpWebhookOperation, - isProperUrl -} from '@stoplight/elements-utils'; \ No newline at end of file + isJSONSchema, + isProperUrl, + isSMDASTRoot, +} from '@stoplight/elements-utils'; diff --git a/packages/elements-core/src/utils/oas/security.ts b/packages/elements-core/src/utils/oas/security.ts index 4c023515c..579cb0e4f 100644 --- a/packages/elements-core/src/utils/oas/security.ts +++ b/packages/elements-core/src/utils/oas/security.ts @@ -1,7 +1,7 @@ export { getReadableSecurityName, - shouldIncludeKey, getReadableSecurityNames, + getSecurityGroupId, shouldAddKey, - getSecurityGroupId -} from '@stoplight/elements-utils' \ No newline at end of file + shouldIncludeKey, +} from '@stoplight/elements-utils'; diff --git a/packages/elements-core/src/utils/securitySchemes.ts b/packages/elements-core/src/utils/securitySchemes.ts index e1bca736a..783deddb4 100644 --- a/packages/elements-core/src/utils/securitySchemes.ts +++ b/packages/elements-core/src/utils/securitySchemes.ts @@ -1,4 +1 @@ -export { - getDefaultDescription, - getOptionalAuthDescription -} from '@stoplight/elements-utils'; \ No newline at end of file +export { getDefaultDescription, getOptionalAuthDescription } from '@stoplight/elements-utils'; diff --git a/packages/elements-core/src/utils/string.ts b/packages/elements-core/src/utils/string.ts index 26ea3e6d9..18151f5b9 100644 --- a/packages/elements-core/src/utils/string.ts +++ b/packages/elements-core/src/utils/string.ts @@ -1,5 +1 @@ -export { - caseInsensitivelyEquals, - slugify -} from '@stoplight/elements-utils'; - +export { caseInsensitivelyEquals, slugify } from '@stoplight/elements-utils'; diff --git a/packages/elements-utils/src/elements/utils/oas/index.ts b/packages/elements-utils/src/elements/utils/oas/index.ts index bb4d1549d..f84d72343 100644 --- a/packages/elements-utils/src/elements/utils/oas/index.ts +++ b/packages/elements-utils/src/elements/utils/oas/index.ts @@ -14,10 +14,11 @@ import { get, isObject, last } from 'lodash'; import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; import { Spec } from 'swagger-schema-official'; -import { slugify } from '../../../elements-core/utils/string' +import { slugify } from '../../../elements-core/utils/string'; import { oas2SourceMap } from './oas2'; import { oas3SourceMap } from './oas3'; import { ISourceNodeMap, NodeTypes, ServiceChildNode, ServiceNode } from './types'; + type OpenAPIObject = _OpenAPIObject & { webhooks?: PathObject; }; diff --git a/packages/elements-utils/src/index.ts b/packages/elements-utils/src/index.ts index 4da95bb95..4b0e5b501 100644 --- a/packages/elements-utils/src/index.ts +++ b/packages/elements-utils/src/index.ts @@ -1,11 +1,8 @@ -export * from './elements-core/utils/guards'; +export type { TagGroup } from './elements/components/API/utils'; +export * from './elements/components/API/utils'; export * from './elements/utils/oas'; export type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from './elements/utils/oas/types'; export * from './elements/utils/oas/types'; -export * from '././elements-core/utils/oas/security'; -export * from './elements-core/utils/oas/security'; -export type { TagGroup } from './elements/components/API/utils'; -export * from './elements/components/API/utils'; export type { TableOfContentsDivider, TableOfContentsExternalLink, @@ -15,4 +12,7 @@ export type { TableOfContentsNode, TableOfContentsNodeGroup, } from './elements-core/components/Docs/types'; +export * from './elements-core/utils/guards'; +export * from './elements-core/utils/oas/security'; +export * from './elements-core/utils/securitySchemes'; export * from './elements-core/utils/string'; diff --git a/packages/elements-utils/tsconfig.build.json b/packages/elements-utils/tsconfig.build.json index 52e2bd408..f7fd0916c 100644 --- a/packages/elements-utils/tsconfig.build.json +++ b/packages/elements-utils/tsconfig.build.json @@ -9,6 +9,5 @@ "compilerOptions": { "baseUrl": "./", "outDir": "dist", - "moduleResolution": "node" } } diff --git a/packages/elements-utils/tsconfig.json b/packages/elements-utils/tsconfig.json index 5388769dc..4bba6944f 100644 --- a/packages/elements-utils/tsconfig.json +++ b/packages/elements-utils/tsconfig.json @@ -3,5 +3,9 @@ "include": ["src"], - "exclude": ["**/__fixtures__/**", "**/__stories__/**", "**/__tests__/**"] + "exclude": ["**/__fixtures__/**", "**/__stories__/**", "**/__tests__/**"], + "compilerOptions": { + "module": "commonjs", + "lib": ["es2021", "dom"] + } } diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index 3abe39fa9..97d34af7c 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -1,4 +1,2 @@ - export type { TagGroup } from '@stoplight/elements-utils'; export { computeAPITree, computeTagGroups, findFirstNodeSlug, isInternal } from '@stoplight/elements-utils'; - diff --git a/packages/elements/src/utils/oas/index.ts b/packages/elements/src/utils/oas/index.ts index 0b7360fdb..3ba449ca8 100644 --- a/packages/elements/src/utils/oas/index.ts +++ b/packages/elements/src/utils/oas/index.ts @@ -1,4 +1 @@ -export { - isJson, - transformOasToServiceNode, -} from '@stoplight/elements-utils'; +export { isJson, transformOasToServiceNode } from '@stoplight/elements-utils'; diff --git a/packages/elements/src/utils/oas/types.ts b/packages/elements/src/utils/oas/types.ts index 902c7df91..13c6c2ed2 100644 --- a/packages/elements/src/utils/oas/types.ts +++ b/packages/elements/src/utils/oas/types.ts @@ -1,12 +1,2 @@ -export { - NodeTypes, - ISourceNodeMap -} from '@stoplight/elements-utils'; - -export type { - ServiceNode, - ServiceChildNode, - OperationNode, - WebhookNode, - SchemaNode -} from '@stoplight/elements-utils'; \ No newline at end of file +export type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '@stoplight/elements-utils'; +export { ISourceNodeMap, NodeTypes } from '@stoplight/elements-utils'; From a322ec0773a8c2a64887b862bd07626bcacde9a2 Mon Sep 17 00:00:00 2001 From: Val Gorodnichev Date: Tue, 10 Dec 2024 07:38:10 -0700 Subject: [PATCH 04/10] fix: folder rename to match original structure --- .../src/components/TableOfContents/types.ts | 20 ++----------------- .../{Docs => TableOfContents}/types.ts | 0 packages/elements-utils/src/index.ts | 4 +++- 3 files changed, 5 insertions(+), 19 deletions(-) rename packages/elements-utils/src/elements-core/components/{Docs => TableOfContents}/types.ts (100%) diff --git a/packages/elements-core/src/components/TableOfContents/types.ts b/packages/elements-core/src/components/TableOfContents/types.ts index bee758e7d..dcc4bb973 100644 --- a/packages/elements-core/src/components/TableOfContents/types.ts +++ b/packages/elements-core/src/components/TableOfContents/types.ts @@ -1,22 +1,5 @@ -import type { TableOfContentsItem } from '@stoplight/elements-utils'; - -export type TableOfContentsProps = { - tree: TableOfContentsItem[]; - activeId: string; - Link: CustomLinkComponent; - maxDepthOpenByDefault?: number; - externalScrollbar?: boolean; - isInResponsiveMode?: boolean; - onLinkClick?(): void; -}; - -export type CustomLinkComponent = React.ComponentType<{ - to: string; - className?: string; - children: React.ReactNode; -}>; - export type { + CustomLinkComponent, TableOfContentsDivider, TableOfContentsExternalLink, TableOfContentsGroup, @@ -24,4 +7,5 @@ export type { TableOfContentsItem, TableOfContentsNode, TableOfContentsNodeGroup, + TableOfContentsProps, } from '@stoplight/elements-utils'; diff --git a/packages/elements-utils/src/elements-core/components/Docs/types.ts b/packages/elements-utils/src/elements-core/components/TableOfContents/types.ts similarity index 100% rename from packages/elements-utils/src/elements-core/components/Docs/types.ts rename to packages/elements-utils/src/elements-core/components/TableOfContents/types.ts diff --git a/packages/elements-utils/src/index.ts b/packages/elements-utils/src/index.ts index 4b0e5b501..d2e6dd2a4 100644 --- a/packages/elements-utils/src/index.ts +++ b/packages/elements-utils/src/index.ts @@ -4,6 +4,7 @@ export * from './elements/utils/oas'; export type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from './elements/utils/oas/types'; export * from './elements/utils/oas/types'; export type { + CustomLinkComponent, TableOfContentsDivider, TableOfContentsExternalLink, TableOfContentsGroup, @@ -11,7 +12,8 @@ export type { TableOfContentsItem, TableOfContentsNode, TableOfContentsNodeGroup, -} from './elements-core/components/Docs/types'; + TableOfContentsProps, +} from './elements-core/components/TableOfContents/types'; export * from './elements-core/utils/guards'; export * from './elements-core/utils/oas/security'; export * from './elements-core/utils/securitySchemes'; From bd1b5e80dccd522125bdde84edf5d65583ec0692 Mon Sep 17 00:00:00 2001 From: Val Gorodnichev Date: Tue, 10 Dec 2024 07:46:57 -0700 Subject: [PATCH 05/10] fix: changing readme and contributing to mention new package --- CONTRIBUTING.md | 69 +++++++++++++++++-------------- packages/elements-utils/README.md | 4 +- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4368548a2..f9bd88b4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,23 +2,28 @@ ## Table Of Contents -- [Intro](#intro) -- [Install Elements](#install-elements) -- [Develop Elements](#develop-elements) -- [Testing](#testing) - - [Guiding principles](#guiding-principles) - - [Unit tests](#unit-tests) - - [Run unit-tests](#run-unit-tests) - - [Framework Integration](#framework-integration) - - [Run tests as the CI would](#run-tests-as-the-ci-would) - - [Run the tests manually](#run-the-tests-manually) - - [Edit the tests](#edit-the-tests) - - [Inspect test results](#inspect-test-results) -- [Yalc into platform-internal](#yalc-into-platform-internal) -- [Release Elements](#release-elements) -- [Versioning Guidelines](#versioning-guidelines) -- [Merge into main](#merge-into-main) - +- [Contributing to Stoplight Elements](#contributing-to-stoplight-elements) + - [Table Of Contents](#table-of-contents) + - [Intro](#intro) + - [Install Elements](#install-elements) + - [Develop Elements](#develop-elements) + - [Testing](#testing) + - [Guiding Principles](#guiding-principles) + - [Unit tests](#unit-tests) + - [Run Unit Tests](#run-unit-tests) + - [Framework Integration](#framework-integration) + - [Run Tests as the CI Would](#run-tests-as-the-ci-would) + - [Run the Tests Manually](#run-the-tests-manually) + - [Edit the Tests](#edit-the-tests) + - [Inspect Test Results](#inspect-test-results) + - [Yalc into platform-internal](#yalc-into-platform-internal) + - [Release Elements](#release-elements) + - [Versioning Guidelines](#versioning-guidelines) + - [Major versions](#major-versions) + - [Minor versions](#minor-versions) + - [Patches](#patches) + - [Merge into Main](#merge-into-main) + ## Intro Elements is an open-source project, and Stoplight loves contributions. If you're familiar with TypeScript and Jest then dive right in, see how far you can get, and post in [Discussions](https://github.com/stoplightio/elements/discussions) or start a draft PR if you get stuck. @@ -41,7 +46,7 @@ To validate that the installation was successful, move into the demo folder by r ## Develop Elements -Elements is split into three packages. Two of them, `elements` and `elements-dev-portal`, are user-facing. The third, `elements-core`, is an implementation detail created to share code and components between `elements` and `elements-dev-portal`. +Elements is split into four packages. Two of them, `elements`, and `elements-dev-portal`, are user-facing. The thirts, `elements-utils` holds implementations and definitions of shared non-react specific code, the fourth, `elements-core`, is an implementation detail created to share code and components between `elements` and `elements-dev-portal`. Most of the code is in `elements-core`; `elements` and `elements-dev-portal` only have code that's highly specific to those projects. @@ -49,7 +54,7 @@ Most often, you'll develop Elements (in all the packages) using [storybooks](htt Each package has its own storybook. To run a storybook for a specific package, in the main directory run, for example, `yarn elements-core storybook`. This starts a storybook for the `elements-core` package. -Now you can develop the code and test your changes in the storybook. +Now you can develop the code and test your changes in the storybook. For your convenience, all the packages are linked. For example, if you run the `elements` storybook, but make changes in the `elements-core` codebase, those changes *will be* visible instantly in your storybook. @@ -57,7 +62,7 @@ For your convenience, all the packages are linked. For example, if you run the ` ### Guiding Principles -The aim should never be to write tests for the sake of writing tests. +The aim should never be to write tests for the sake of writing tests. For Stoplight, the goal of testing is **to give developers confidence** when making changes to the codebase when they're adding new features, cleaning up tech debt, or fixing bugs. @@ -65,15 +70,15 @@ Well-written tests also **save time** when authoring or reviewing PRs as you don To achieve high-quality tests, **follow these principles**: - Always test the **behavior** of a component, not the implementation. - - Tests that use *Jest snapshots* almost always violate this. (Except maybe when you are testing an AST parser, a linter, or similar.) - + - Tests that use *Jest snapshots* almost always violate this. (Except maybe when you are testing an AST parser, a linter, or similar.) + **Instead,** extract the real business requirement from what would be the snapshot and assert against that. - - - Tests that `find` child components and assert against props being passed may be incorrect. + + - Tests that `find` child components and assert against props being passed may be incorrect. Use the **recommended selectors** (see point below) and **[`jest-dom` assertions](https://github.com/testing-library/jest-dom)** to enforce constraints that matter to the user. - Searching for DOM elements using tag name, CSS class, or hierarchy (`parentElement`, etc.) is an anti-pattern. - **Instead,** use **`findByRole` or other queries from [TL's query hierarchy](https://testing-library.com/docs/queries/about#priority)**. + **Instead,** use **`findByRole` or other queries from [TL's query hierarchy](https://testing-library.com/docs/queries/about#priority)**. Feel free to add accessibility attributes where missing. With a bit of practice, you'll see that almost everything can be covered with `*byRole`. - The goal for your test suite is **to cover** as much of the **business requirements** (for example, in the issue description) as practical. - An ideal test suite only requires a change **if business requirements change**. @@ -95,11 +100,11 @@ Unit testing stack: - [JSDOM](https://github.com/jsdom/jsdom) - [React Testing Library](https://github.com/testing-library/react-testing-library/) - [Jest-DOM](https://github.com/testing-library/jest-dom) -- \* You can find some legacy code utilizing a different stack (Enzyme). When changing those tests, use your judgment +- \* You can find some legacy code utilizing a different stack (Enzyme). When changing those tests, use your judgment to decide between amending the old unit test or rewriting it using the new stack. Mixing testing libraries in a single test file is fine. - + Unit tests are currently located in a directory `__tests__` close to the component being tested, but in the future, tests will be located right next to the components under test, with a `.spec.ts` extension. - + #### Run Unit Tests Assuming you work on the `elements` package, you can run `jest` on it using the shorthand @@ -144,7 +149,7 @@ That being said, if you for some reason want to run them by hand, here's how to ```shell # Make sure top-level dependencies are up-to-date -yarn +yarn # Build Elements itself yarn build # Copy the contents of the predefined example application and make it use the local build @@ -164,7 +169,7 @@ The first three steps are the same, but this time, instead of running the tests ```shell # Make sure top-level dependencies are up-to-date -yarn +yarn # Build Elements itself yarn build # Copy the contents of the predefined example application and make it use the local build @@ -190,7 +195,7 @@ Fixtures, *Cypress* plugins, and support files can also be found in the relevant #### Inspect Test Results -Test results can be found under the `cypress/results` directory. +Test results can be found under the `cypress/results` directory. *Cypress* records videos of every test suite run and takes screenshots for every failure. In addition, a machine-readable and human-readable `output.xml` is generated that contains a summary of the results. When running in *CircleCI*, the host interprets `output.xml` and displays it visually on the dashboard: @@ -237,7 +242,7 @@ If you think a major version bump is required in *any* `elements` package, pleas Minor versions in `elements` and `elements-dev-portal` are for introducing new features. If *any* change that's being released introduces a new feature / somehow extends the functionality, bump the minor. -In the case of `elements-core` (and in contrast with two other packages), minors are allowed to have (within reason) some breaking changes. That's because it's an internal package that Stoplight controls. +In the case of `elements-core` (and in contrast with two other packages), minors are allowed to have (within reason) some breaking changes. That's because it's an internal package that Stoplight controls. If you need to make a breaking change in `elements-core`, make sure to bump minor *and* make sure that the new versions of `elements` and `elements-dev-portal` are using this new version and are compatible with it. Remember also that `elements` is used in internal Stoplight platform code, so make sure that the new version also works correctly there. diff --git a/packages/elements-utils/README.md b/packages/elements-utils/README.md index 5354254f3..1ff120d4d 100644 --- a/packages/elements-utils/README.md +++ b/packages/elements-utils/README.md @@ -1,7 +1,7 @@ # Elements utils -## Code to extract table of contents from ACT generated by `http-spec` +## Code to extract table of contents from AST generated by `http-spec` -## COde to provide readable descriptions for security sections extracted from ACT generated by `http-spec` +## COde to provide readable descriptions for security sections extracted from AST generated by `http-spec` From 02421a8e88b436dfbf0a45be267edb98df5a0658 Mon Sep 17 00:00:00 2001 From: Val Gorodnichev Date: Tue, 10 Dec 2024 07:52:20 -0700 Subject: [PATCH 06/10] fix: exports --- packages/elements-core/src/index.ts | 2 +- packages/elements-dev-portal/src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/elements-core/src/index.ts b/packages/elements-core/src/index.ts index a6b41538e..1ab71587c 100644 --- a/packages/elements-core/src/index.ts +++ b/packages/elements-core/src/index.ts @@ -14,7 +14,6 @@ export { ReactRouterMarkdownLink } from './components/MarkdownViewer/CustomCompo export { NonIdealState } from './components/NonIdealState'; export { PoweredByLink } from './components/PoweredByLink'; export { TableOfContents } from './components/TableOfContents'; -export { CustomLinkComponent } 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'; @@ -39,6 +38,7 @@ export { createResolvedObject } from './utils/ref-resolving/resolvedObject'; export { slugify } from './utils/string'; export { createElementClass } from './web-components/createElementClass'; export type { + CustomLinkComponent, TableOfContentsGroup, TableOfContentsItem, TableOfContentsNode, diff --git a/packages/elements-dev-portal/src/version.ts b/packages/elements-dev-portal/src/version.ts index ecb0c1b7a..c6b81bdba 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 = '2.5.2'; +export const appVersion = '2.4.2'; From 09e4b7b9a9793e53ae1d8a5dd57436b419956595 Mon Sep 17 00:00:00 2001 From: Val Gorodnichev Date: Wed, 11 Dec 2024 08:09:52 -0700 Subject: [PATCH 07/10] fix: add postinstall script to build elements-utils after install, as this package is not yet build - ci steps will faill attempting to find it without building --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index d22d34538..72e74b52b 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@stoplight/react-error-boundary": "3.0.0" }, "scripts": { + "postinstall": "yarn workspace @stoplight/elements-utils build", "demo": "yarn workspace @stoplight/elements-demo", "elements": "yarn workspace @stoplight/elements", "elements-core": "yarn workspace @stoplight/elements-core", From 7d454f9f9bb2edd61188307db07a23b97f777f84 Mon Sep 17 00:00:00 2001 From: Val Gorodnichev Date: Thu, 12 Dec 2024 08:58:25 -0700 Subject: [PATCH 08/10] fix: add yarn install to eun-e2e-tests to deal with new non-published package --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index fc1fe9515..5bb673e52 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -58,6 +58,7 @@ jobs: steps: - attach_workspace: at: /mnt/ramdisk/ + - run: yarn --frozen-lockfile - run: name: Example - use local builds working_directory: /mnt/ramdisk/project From 0f65c3f3f0e54cdce4fbaf653a9f489c82d2a00e Mon Sep 17 00:00:00 2001 From: Val Gorodnichev Date: Thu, 12 Dec 2024 09:07:15 -0700 Subject: [PATCH 09/10] fix: making e2e working different way --- .circleci/config.yml | 1 - package.json | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5bb673e52..fc1fe9515 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -58,7 +58,6 @@ jobs: steps: - attach_workspace: at: /mnt/ramdisk/ - - run: yarn --frozen-lockfile - run: name: Example - use local builds working_directory: /mnt/ramdisk/project diff --git a/package.json b/package.json index 72e74b52b..ef16ca332 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "test.prod": "yarn workspace @stoplight/elements test.prod && yarn workspace @stoplight/elements-core test.prod && yarn workspace @stoplight/elements-dev-portal test.prod --passWithNoTests && yarn workspace @stoplight/elements-utils test.prod --passWithNoTests", "prepublishOnly": "yarn build", "/// examples ///": "", + "precopy:angular": "yarn workspace @stoplight/elements-utils build", "copy:angular": "mkdir examples-dev ; cp -a -v ./examples/angular ./examples-dev ; sh ./use-local-elements.sh angular", "build:angular": "(cd ./examples-dev/angular && yarn reinstall && yarn build)", "serve:angular": "(cd ./examples-dev/angular && yarn serve)", From 3e5e4d8746e56516944069b56b48376417f64dff Mon Sep 17 00:00:00 2001 From: Val Gorodnichev Date: Thu, 12 Dec 2024 09:18:41 -0700 Subject: [PATCH 10/10] fix: add elements-utils to usee-local-elements script --- package.json | 1 - use-local-elements.sh | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ef16ca332..72e74b52b 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,6 @@ "test.prod": "yarn workspace @stoplight/elements test.prod && yarn workspace @stoplight/elements-core test.prod && yarn workspace @stoplight/elements-dev-portal test.prod --passWithNoTests && yarn workspace @stoplight/elements-utils test.prod --passWithNoTests", "prepublishOnly": "yarn build", "/// examples ///": "", - "precopy:angular": "yarn workspace @stoplight/elements-utils build", "copy:angular": "mkdir examples-dev ; cp -a -v ./examples/angular ./examples-dev ; sh ./use-local-elements.sh angular", "build:angular": "(cd ./examples-dev/angular && yarn reinstall && yarn build)", "serve:angular": "(cd ./examples-dev/angular && yarn serve)", diff --git a/use-local-elements.sh b/use-local-elements.sh index 8c66a4eae..1b4dd82ca 100644 --- a/use-local-elements.sh +++ b/use-local-elements.sh @@ -7,6 +7,7 @@ npx json -I -f package.json -e "this.resolutions=this.resolutions || {}" npx json -I -f package.json -e "this.resolutions[\"@stoplight/elements\"]=\"file:../../packages/elements/dist\"" npx json -I -f package.json -e "this.resolutions[\"@stoplight/elements-dev-portal\"]=\"file:../../packages/elements-dev-portal/dist\"" npx json -I -f package.json -e "this.resolutions[\"@stoplight/elements-core\"]=\"file:../../packages/elements-core/dist\"" +npx json -I -f package.json -e "this.resolutions[\"@stoplight/elements-utils\"]=\"file:../../packages/elements-utils/dist\"" npx json -I -f package.json -e "this.dependencies[\"@stoplight/elements\"]=\"file:../../packages/elements/dist\"" npx json -I -f package.json -e "this.dependencies[\"@stoplight/elements-dev-portal\"]=\"file:../../packages/elements-dev-portal/dist\""