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/package.json b/package.json index a47076544..72e74b52b 100644 --- a/package.json +++ b/package.json @@ -72,20 +72,23 @@ "@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", "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..dcc4bb973 100644 --- a/packages/elements-core/src/components/TableOfContents/types.ts +++ b/packages/elements-core/src/components/TableOfContents/types.ts @@ -1,51 +1,11 @@ -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; +export type { + CustomLinkComponent, + TableOfContentsDivider, + TableOfContentsExternalLink, + TableOfContentsGroup, + TableOfContentsGroupItem, + TableOfContentsItem, + TableOfContentsNode, + TableOfContentsNodeGroup, + TableOfContentsProps, +} from '@stoplight/elements-utils'; diff --git a/packages/elements-core/src/index.ts b/packages/elements-core/src/index.ts index fd1df3790..1ab71587c 100644 --- a/packages/elements-core/src/index.ts +++ b/packages/elements-core/src/index.ts @@ -14,13 +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, - TableOfContentsGroup, - TableOfContentsItem, - TableOfContentsNode, - TableOfContentsNodeGroup, -} 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 +37,10 @@ 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 { + CustomLinkComponent, + 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..66d9e9d5c 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 { + isHttpOperation, + isHttpService, + isHttpWebhookOperation, + 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 99352c161..579cb0e4f 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, + getReadableSecurityNames, + getSecurityGroupId, + shouldAddKey, + shouldIncludeKey, +} from '@stoplight/elements-utils'; diff --git a/packages/elements-core/src/utils/securitySchemes.ts b/packages/elements-core/src/utils/securitySchemes.ts index e5de03a51..783deddb4 100644 --- a/packages/elements-core/src/utils/securitySchemes.ts +++ b/packages/elements-core/src/utils/securitySchemes.ts @@ -1,117 +1 @@ -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'; diff --git a/packages/elements-core/src/utils/string.ts b/packages/elements-core/src/utils/string.ts index 64cf7c11a..18151f5b9 100644 --- a/packages/elements-core/src/utils/string.ts +++ b/packages/elements-core/src/utils/string.ts @@ -1,11 +1 @@ -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(/-$/, ''); -} +export { caseInsensitivelyEquals, slugify } from '@stoplight/elements-utils'; 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..1ff120d4d --- /dev/null +++ b/packages/elements-utils/README.md @@ -0,0 +1,7 @@ +# Elements utils + +## Code to extract table of contents from AST generated by `http-spec` + +## COde to provide readable descriptions for security sections extracted from AST 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/elements-core/components/TableOfContents/types.ts b/packages/elements-utils/src/elements-core/components/TableOfContents/types.ts new file mode 100644 index 000000000..e3c69e15d --- /dev/null +++ b/packages/elements-utils/src/elements-core/components/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/elements-core/utils/guards.ts b/packages/elements-utils/src/elements-core/utils/guards.ts new file mode 100644 index 000000000..e4d918b3a --- /dev/null +++ b/packages/elements-utils/src/elements-core/utils/guards.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-core/src/utils/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-core/src/utils/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/elements-core/utils/oas/security.ts b/packages/elements-utils/src/elements-core/utils/oas/security.ts new file mode 100644 index 000000000..99352c161 --- /dev/null +++ b/packages/elements-utils/src/elements-core/utils/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/elements-core/utils/securitySchemes.ts b/packages/elements-utils/src/elements-core/utils/securitySchemes.ts new file mode 100644 index 000000000..e5de03a51 --- /dev/null +++ b/packages/elements-utils/src/elements-core/utils/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/elements-core/utils/string.ts b/packages/elements-utils/src/elements-core/utils/string.ts new file mode 100644 index 000000000..64cf7c11a --- /dev/null +++ b/packages/elements-utils/src/elements-core/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/src/components/API/__tests__/utils.test.ts b/packages/elements-utils/src/elements/components/API/__tests__/utils.test.ts similarity index 100% rename from packages/elements/src/components/API/__tests__/utils.test.ts rename to packages/elements-utils/src/elements/components/API/__tests__/utils.test.ts diff --git a/packages/elements-utils/src/elements/components/API/utils.ts b/packages/elements-utils/src/elements/components/API/utils.ts new file mode 100644 index 000000000..1c0eecd10 --- /dev/null +++ b/packages/elements-utils/src/elements/components/API/utils.ts @@ -0,0 +1,193 @@ +// @ts-nocheck external file +import { NodeType } from '@stoplight/types'; +import { defaults } from 'lodash'; + +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; + +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/__tests__/oas.spec.ts b/packages/elements-utils/src/elements/utils/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/elements/utils/oas/__tests__/oas.spec.ts diff --git a/packages/elements-utils/src/elements/utils/oas/index.ts b/packages/elements-utils/src/elements/utils/oas/index.ts new file mode 100644 index 000000000..f84d72343 --- /dev/null +++ b/packages/elements-utils/src/elements/utils/oas/index.ts @@ -0,0 +1,192 @@ +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 '../../../elements-core/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/elements/utils/oas/oas2.ts similarity index 100% rename from packages/elements/src/utils/oas/oas2.ts rename to packages/elements-utils/src/elements/utils/oas/oas2.ts diff --git a/packages/elements/src/utils/oas/oas3.ts b/packages/elements-utils/src/elements/utils/oas/oas3.ts similarity index 100% rename from packages/elements/src/utils/oas/oas3.ts rename to packages/elements-utils/src/elements/utils/oas/oas3.ts diff --git a/packages/elements-utils/src/elements/utils/oas/types.ts b/packages/elements-utils/src/elements/utils/oas/types.ts new file mode 100644 index 000000000..8d22cc546 --- /dev/null +++ b/packages/elements-utils/src/elements/utils/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-utils/src/index.ts b/packages/elements-utils/src/index.ts new file mode 100644 index 000000000..d2e6dd2a4 --- /dev/null +++ b/packages/elements-utils/src/index.ts @@ -0,0 +1,20 @@ +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 type { + CustomLinkComponent, + TableOfContentsDivider, + TableOfContentsExternalLink, + TableOfContentsGroup, + TableOfContentsGroupItem, + TableOfContentsItem, + TableOfContentsNode, + TableOfContentsNodeGroup, + 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'; +export * from './elements-core/utils/string'; diff --git a/packages/elements-utils/tsconfig.build.json b/packages/elements-utils/tsconfig.build.json new file mode 100644 index 000000000..f7fd0916c --- /dev/null +++ b/packages/elements-utils/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src" + ], + "exclude": [ + "**/__*__/**" + ], + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + } +} diff --git a/packages/elements-utils/tsconfig.json b/packages/elements-utils/tsconfig.json new file mode 100644 index 000000000..4bba6944f --- /dev/null +++ b/packages/elements-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src"], + + "exclude": ["**/__fixtures__/**", "**/__stories__/**", "**/__tests__/**"], + "compilerOptions": { + "module": "commonjs", + "lib": ["es2021", "dom"] + } +} 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/utils.ts b/packages/elements/src/components/API/utils.ts index 1634cf5ff..97d34af7c 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -1,196 +1,2 @@ -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'; - -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, - }); - } - }); -}; +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 14e425c64..3ba449ca8 100644 --- a/packages/elements/src/utils/oas/index.ts +++ b/packages/elements/src/utils/oas/index.ts @@ -1,192 +1 @@ -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..13c6c2ed2 100644 --- a/packages/elements/src/utils/oas/types.ts +++ b/packages/elements/src/utils/oas/types.ts @@ -1,34 +1,2 @@ -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 type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '@stoplight/elements-utils'; +export { ISourceNodeMap, NodeTypes } from '@stoplight/elements-utils'; 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\""