diff --git a/.changeset/light-pigs-protect.md b/.changeset/light-pigs-protect.md new file mode 100644 index 0000000000..358547af8a --- /dev/null +++ b/.changeset/light-pigs-protect.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Fix bug with setting owner in RepoUrlPicker causing validation failure diff --git a/.changeset/olive-pens-poke.md b/.changeset/olive-pens-poke.md new file mode 100644 index 0000000000..8a009af971 --- /dev/null +++ b/.changeset/olive-pens-poke.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Use ellipsis style for overflowed text in sidebar menu diff --git a/.changeset/rotten-candles-poke.md b/.changeset/rotten-candles-poke.md new file mode 100644 index 0000000000..b8a5b7138b --- /dev/null +++ b/.changeset/rotten-candles-poke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Update AsyncAPI component to 1.0.0-x releases diff --git a/.changeset/techdocs-satisfied-you-blinked.md b/.changeset/techdocs-satisfied-you-blinked.md new file mode 100644 index 0000000000..4cbc9d4f3e --- /dev/null +++ b/.changeset/techdocs-satisfied-you-blinked.md @@ -0,0 +1,7 @@ +--- +'@backstage/techdocs-common': minor +'@backstage/plugin-techdocs-backend': minor +--- + +Added the ability for the TechDocs Backend to (optionally) leverage a cache +store to improve performance when reading files from a cloud storage provider. diff --git a/.changeset/the-renegade-feeling.md b/.changeset/the-renegade-feeling.md new file mode 100644 index 0000000000..c9a4b1965e --- /dev/null +++ b/.changeset/the-renegade-feeling.md @@ -0,0 +1,42 @@ +--- +'@backstage/create-app': patch +--- + +TechDocs Backend may now (optionally) leverage a cache store to improve +performance when reading content from a cloud storage provider. + +To apply this change to an existing app, pass the cache manager from the plugin +environment to the `createRouter` function in your backend: + +```diff +// packages/backend/src/plugins/techdocs.ts + +export default async function createPlugin({ + logger, + config, + discovery, + reader, ++ cache, +}: PluginEnvironment): Promise { + + // ... + + return await createRouter({ + preparers, + generators, + publisher, + logger, + config, + discovery, ++ cache, + }); +``` + +If your `PluginEnvironment` does not include a cache manager, be sure you've +applied [the cache management change][cm-change] to your backend as well. + +[Additional configuration][td-rec-arch] is required if you wish to enable +caching in TechDocs. + +[cm-change]: https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md#patch-changes-6 +[td-rec-arch]: https://backstage.io/docs/features/techdocs/architecture#recommended-deployment diff --git a/.changeset/thirty-readers-attack.md b/.changeset/thirty-readers-attack.md new file mode 100644 index 0000000000..cd05f4ebf4 --- /dev/null +++ b/.changeset/thirty-readers-attack.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Bump @spotify/prettier-config diff --git a/.changeset/three-coins-kiss.md b/.changeset/three-coins-kiss.md new file mode 100644 index 0000000000..a4b0c83288 --- /dev/null +++ b/.changeset/three-coins-kiss.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-xcmetrics': patch +--- + +Handle a case where XCode data from backend (before 0.0.8) could be missing diff --git a/.changeset/tiny-trains-notice.md b/.changeset/tiny-trains-notice.md new file mode 100644 index 0000000000..e6d260633e --- /dev/null +++ b/.changeset/tiny-trains-notice.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Removed the `scaffolder.github.visibility` configuration that is no longer used from the default app template. diff --git a/ADOPTERS.md b/ADOPTERS.md index 529c22d144..3a04099812 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -73,3 +73,5 @@ | [QBE](https://www.qbe.com/) | [Daniel Steel](https://github.com/danielsteelqbe), [Pete Jespers](https://github.com/petejespersqbe) | Developer portal allowing our global teams to explore and create applications, documentation and cloud infrastructure easily and quickly 🚀 | | [LogMeIn](https://www.logmein.com) | [Lorenzo Orsatti](https://github.com/lorsatti) | Improve onboarding experience of new developers. Discover faster and painlessly developer documentation, API definitions and team information. Provide useful dev metrics in a central place. Provide easy-to-use templates for new services. | | [Telstra](https://www.telstra.com.au) | [@kiranpatel11](https://github.com/kiranpatel11), [JasonC](https://github.com/JasonC17) | Primary usage: software catalog and templates
Emerging usage : TechDocs, Explore Ecosystem, TechRadar, etc | +| [Mosaico](https://www.mosaico.com.br/) | [Wédney Yuri](https://github.com/wedneyyuri),[@tino.milton](https://github.com/miltonjacomini) | A centralized service catalog of our documentation for our service engineers. | +| [Mox Bank](https://www.mox.com/) | [Nick Laqua](https://github.com/nick-laqua-dragon), [Gauthier Roebroeck](https://github.com/gauthier-roebroeck-mox) | "Single pane of glass" developer portal for providing a best-in-class developer experience to our product teams and making Mox the best tech environment in Hongkong 🥰🚀 | diff --git a/docs/assets/techdocs/architecture-recommended.drawio.svg b/docs/assets/techdocs/architecture-recommended.drawio.svg index e3af4b6b5f..1768b5dc86 100644 --- a/docs/assets/techdocs/architecture-recommended.drawio.svg +++ b/docs/assets/techdocs/architecture-recommended.drawio.svg @@ -1,4 +1,4 @@ - + @@ -7,7 +7,7 @@ - + @@ -67,8 +67,8 @@ - - + + @@ -105,7 +105,7 @@ - + @@ -124,7 +124,7 @@ - + @@ -232,34 +232,17 @@
- TechDocs plugin + TechDocs Plugin
- TechDocs plugin + TechDocs Plugin - - - - -
-
-
- TechDocs Backend plugin -
-
-
-
- - TechDocs Backend plu... - -
-
- + @@ -268,21 +251,23 @@
- Request TechDocs site + + Request TechDocs Site +
- Request TechDocs site + Request TechDocs Site
- + -
+
Fetch files to render @@ -290,7 +275,7 @@
- + Fetch files to render @@ -302,15 +287,15 @@ - + - + -
+
Source code hosting @@ -323,28 +308,9 @@ - - - - - -
-
-
- Caching -
- (Optional) -
-
-
-
- - Caching... - -
-
- - + + + @@ -379,10 +345,132 @@ + + + + +
+
+
+ Cache Store +
+ (Optional) +
+
+
+
+ + Cache Store... + +
+
+ + + + +
+
+
+ Read/Write +
+ Objects +
+
+
+
+ + Read/Write... + +
+
+ + + + + + + +
+
+
+ + + Memcache + + +
+
+
+
+ + Memcache + +
+
+ + + + + +
+
+
+ + TechDocs Service + +
+
+
+
+ + TechDocs Service + +
+
+ + + + +
+
+
+ + Cache Middleware +
+ (Optional) +
+
+
+
+
+ + Cache Middleware... + +
+
+ + + + + + +
+
+
+ TechDocs Backend Plugin +
+
+
+
+ + TechDocs Backend Plugin + +
+
- + Viewer does not support full SVG 1.1 diff --git a/docs/features/software-templates/configuration.md b/docs/features/software-templates/configuration.md index b57bb96818..d22b82bc16 100644 --- a/docs/features/software-templates/configuration.md +++ b/docs/features/software-templates/configuration.md @@ -18,17 +18,31 @@ The next step is to add [add templates](http://backstage.io/docs/features/software-templates/adding-templates) to your Backstage app. -### GitHub +### Publishing defaults -For GitHub, you can configure who can see the new repositories that are created -by specifying `visibility` option. Valid options are `public`, `private` and -`internal`. The `internal` option is for GitHub Enterprise clients, which means -public within the enterprise. +Software templates can define _publish_ actions, such as `publish:github`, to +create new repositories or submit pull / merge requests to existing +repositories. You can configure the author and commit message through the +`scaffolder` configuration in `app-config.yaml`: ```yaml scaffolder: - github: - visibility: public # or 'internal' or 'private' + defaultAuthor: + name: M.C. Hammer # Defaults to `Scaffolder` + email: hammer@donthurtem.com # Defaults to `scaffolder@backstage.io` + defaultCommitMessage: "U can't touch this" # Defaults to 'Initial commit' +``` + +To configure who can see the new repositories created from software templates, +add the `repoVisibility` key within a software template: + +```yaml +- id: publish + name: Publish + action: publish:github + input: + repoUrl: '{{ parameters.repoUrl }}' + repoVisibility: public # or 'internal' or 'private' ``` ### Disabling Docker in Docker situation (Optional) diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index 8d25b4047f..23af83d09b 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -40,7 +40,7 @@ storage system (e.g. AWS S3, GCS or Azure Blob Storage). Read more in ## Recommended deployment -This is how we recommend deploying TechDocs in production environment. +This is how we recommend deploying TechDocs in a production environment. TechDocs Architecture diagram @@ -58,12 +58,12 @@ Similar to how it is done in the Basic setup, the TechDocs Reader requests your configured storage solution for the necessary files and returns them to TechDocs Reader. -Note about caching: We have noticed internally that some storage providers can -be quite slow, which is why we are recommending a cache that sits between the -TechDocs Reader and the Storage. - -_Feel free to suggest better ideas to us in #docs-like-code channel in Discord -or via a GitHub issue._ +Depending on your chosen cloud storage provider and its real-world proximity to +your backend server, there may be a comparably high amount of latency when +loading TechDocs sites using this deployment approach. If you encounter this, +you can optionally configure the `techdocs-backend` to cache responses in a +cache store +[supported by Backstage](../../overview/architecture-overview.md#cache). ### Security consideration diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 4148749a62..6317ae365b 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -135,6 +135,22 @@ techdocs: # the old, case-sensitive entity triplet behavior. legacyUseCaseSensitiveTripletPaths: false + # techdocs.cache is optional, and is only recommended when you've configured + # an external techdocs.publisher.type above. Also requires backend.cache to + # be configured with a valid cache store. + cache: + # Represents the number of milliseconds a statically built asset should + # stay cached. Cache invalidation is handled automatically by the frontend, + # which compares the build times in cached metadata vs. canonical storage, + # allowing long TTLs (e.g. 1 month/year) + ttl: 3600000 + + # (Optional) The time (in milliseconds) that the TechDocs backend will wait + # for a cache service to respond before continuing on as though the cached + # object was not found (e.g. when the cache sercice is unavailable). The + # default value is 1000 + readTimeout: 500 + # (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. # You don't have to specify this anymore. diff --git a/microsite/package.json b/microsite/package.json index a24de8935d..0a90cb2ec5 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -19,7 +19,7 @@ "@spotify/prettier-config": "^12.0.0", "docusaurus": "^2.0.0-alpha.70", "js-yaml": "^4.1.0", - "prettier": "^2.5.0", + "prettier": "^2.5.1", "yarn-lock-check": "^1.0.5" }, "prettier": "@spotify/prettier-config" diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 9946f4914d..b4d97d549d 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -5190,10 +5190,10 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^2.5.0: - version "2.5.0" - resolved "https://registry.npmjs.org/prettier/-/prettier-2.5.0.tgz#a6370e2d4594e093270419d9cc47f7670488f893" - integrity sha512-FM/zAKgWTxj40rH03VxzIPdXmj39SwSjwG0heUcNFwI+EMZJnY93yAiKXM3dObIKAM5TA88werc8T/EwhB45eg== +prettier@^2.5.1: + version "2.5.1" + resolved "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a" + integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg== prismjs@^1.22.0: version "1.25.0" diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index eb1e0502db..c32bbccbb0 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -29,6 +29,7 @@ export default async function createPlugin({ config, discovery, reader, + cache, }: PluginEnvironment): Promise { // Preparers are responsible for fetching source files for documentation. const preparers = await Preparers.fromConfig(config, { @@ -64,5 +65,6 @@ export default async function createPlugin({ logger, config, discovery, + cache, }); } diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index caaab2a84a..b7369addf6 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -300,7 +300,7 @@ const SidebarItemWithSubmenu = ({ {itemIcon}
{text && ( - + {text} )} @@ -467,7 +467,7 @@ export const SidebarItem = forwardRef((props, ref) => { {itemIcon}
{text && ( - + {text} )} diff --git a/packages/create-app/README.md b/packages/create-app/README.md index 9f86cdb448..d5b5039d4e 100644 --- a/packages/create-app/README.md +++ b/packages/create-app/README.md @@ -22,4 +22,4 @@ yarn backstage-create-app ## Documentation - [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) +- [Backstage Documentation](https://backstage.io/docs/) diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts index d301792380..ac42ed0ff3 100644 --- a/packages/create-app/src/createApp.ts +++ b/packages/create-app/src/createApp.ts @@ -65,7 +65,7 @@ export default async (cmd: Command, version: string): Promise => { const templateDir = paths.resolveOwn('templates/default-app'); const tempDir = resolvePath(os.tmpdir(), answers.name); - // Use `--path` argument as applicaiton directory when specified, otherwise + // Use `--path` argument as application directory when specified, otherwise // create a directory using `answers.name` const appDir = cmd.path ? resolvePath(paths.targetDir, cmd.path) diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index be144d91f7..c804b4b561 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -77,9 +77,7 @@ auth: providers: {} scaffolder: - github: - token: ${GITHUB_TOKEN} - visibility: public # or 'internal' or 'private' + # see https://backstage.io/docs/features/software-templates/configuration for software template options catalog: rules: diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index bdae71d9e3..b0747c4454 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -31,7 +31,7 @@ }, "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", - "@spotify/prettier-config": "^11.0.0", + "@spotify/prettier-config": "^12.0.0", "concurrently": "^6.0.0", "lerna": "^4.0.0", "prettier": "^2.3.2" diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index 9e15608a01..68848d2de9 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -22,7 +22,6 @@ "@backstage/plugin-tech-radar": "^{{version '@backstage/plugin-tech-radar'}}", "@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}", "@backstage/plugin-user-settings": "^{{version '@backstage/plugin-user-settings'}}", - "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@backstage/theme": "^{{version '@backstage/theme'}}", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -34,6 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { + "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", @@ -48,11 +48,11 @@ "scripts": { "start": "backstage-cli app:serve", "build": "backstage-cli app:build", - "test": "backstage-cli test", - "lint": "backstage-cli lint", "clean": "backstage-cli clean", + "test": "backstage-cli test", "test:e2e": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:dev", "test:e2e:ci": "cross-env PORT=3001 start-server-and-test start http://localhost:3001 cy:run", + "lint": "backstage-cli lint", "cy:dev": "cypress open", "cy:run": "cypress run" }, diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts index 906d86d4a2..054c64db65 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -14,6 +14,7 @@ export default async function createPlugin({ config, discovery, reader, + cache, }: PluginEnvironment): Promise { // Preparers are responsible for fetching source files for documentation. const preparers = await Preparers.fromConfig(config, { @@ -49,5 +50,6 @@ export default async function createPlugin({ logger, config, discovery, + cache, }); } diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md index 61371525e5..b2f9e40d0a 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -287,6 +287,8 @@ export type TechDocsMetadata = { site_name: string; site_description: string; etag: string; + build_timestamp: number; + files?: string[]; }; // Warning: (ae-missing-release-tag) "transformDirLocation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/packages/techdocs-common/src/stages/generate/helpers.test.ts b/packages/techdocs-common/src/stages/generate/helpers.test.ts index 9e01c3e2ad..9323ab4f18 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.test.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.test.ts @@ -23,7 +23,7 @@ import os from 'os'; import path, { resolve as resolvePath } from 'path'; import { ParsedLocationAnnotation } from '../../helpers'; import { - addBuildTimestampMetadata, + createOrUpdateMetadata, getGeneratorKey, getMkdocsYml, getRepoUrlFromLocationAnnotation, @@ -369,13 +369,15 @@ describe('helpers', () => { }); describe('addBuildTimestampMetadata', () => { + const mockFiles = { + 'invalid_techdocs_metadata.json': 'dsds', + 'techdocs_metadata.json': '{"site_name": "Tech Docs"}', + }; + beforeEach(() => { mockFs.restore(); mockFs({ - [rootDir]: { - 'invalid_techdocs_metadata.json': 'dsds', - 'techdocs_metadata.json': '{"site_name": "Tech Docs"}', - }, + [rootDir]: mockFiles, }); }); @@ -385,7 +387,7 @@ describe('helpers', () => { it('should create the file if it does not exist', async () => { const filePath = path.join(rootDir, 'wrong_techdocs_metadata.json'); - await addBuildTimestampMetadata(filePath, mockLogger); + await createOrUpdateMetadata(filePath, mockLogger); // Check if the file exists await expect( @@ -397,18 +399,28 @@ describe('helpers', () => { const filePath = path.join(rootDir, 'invalid_techdocs_metadata.json'); await expect( - addBuildTimestampMetadata(filePath, mockLogger), + createOrUpdateMetadata(filePath, mockLogger), ).rejects.toThrowError('Unexpected token d in JSON at position 0'); }); it('should add build timestamp to the metadata json', async () => { const filePath = path.join(rootDir, 'techdocs_metadata.json'); - await addBuildTimestampMetadata(filePath, mockLogger); + await createOrUpdateMetadata(filePath, mockLogger); const json = await fs.readJson(filePath); expect(json.build_timestamp).toBeLessThanOrEqual(Date.now()); }); + + it('should add list of files to the metadata json', async () => { + const filePath = path.join(rootDir, 'techdocs_metadata.json'); + + await createOrUpdateMetadata(filePath, mockLogger); + + const json = await fs.readJson(filePath); + expect(json.files[0]).toEqual(Object.keys(mockFiles)[0]); + expect(json.files[1]).toEqual(Object.keys(mockFiles)[1]); + }); }); describe('storeEtagMetadata', () => { diff --git a/packages/techdocs-common/src/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts index f77b557076..794200fb7d 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -27,6 +27,7 @@ import { PassThrough, Writable } from 'stream'; import { Logger } from 'winston'; import { ParsedLocationAnnotation } from '../../helpers'; import { SupportedGeneratorKey } from './types'; +import { getFileTreeRecursively } from '../publish/helpers'; // TODO: Implement proper support for more generators. export function getGeneratorKey(entity: Entity): SupportedGeneratorKey { @@ -345,14 +346,20 @@ export const patchIndexPreBuild = async ({ }; /** - * Update the techdocs_metadata.json to add a new build timestamp metadata. Create the .json file if it doesn't exist. + * Create or update the techdocs_metadata.json. Values initialized/updated are: + * - The build_timestamp (now) + * - The list of files generated * * @param {string} techdocsMetadataPath File path to techdocs_metadata.json */ -export const addBuildTimestampMetadata = async ( +export const createOrUpdateMetadata = async ( techdocsMetadataPath: string, logger: Logger, ): Promise => { + const techdocsMetadataDir = techdocsMetadataPath + .split(path.sep) + .slice(0, -1) + .join(path.sep); // check if file exists, create if it does not. try { await fs.access(techdocsMetadataPath, fs.constants.F_OK); @@ -372,6 +379,19 @@ export const addBuildTimestampMetadata = async ( } json.build_timestamp = Date.now(); + + // Get and write generated files to the metadata JSON. Each file string is in + // a form appropriate for invalidating the associated object from cache. + try { + json.files = (await getFileTreeRecursively(techdocsMetadataDir)).map(file => + file.replace(`${techdocsMetadataDir}${path.sep}`, ''), + ); + } catch (err) { + assertError(err); + json.files = []; + logger.warn(`Unable to add files list to metadata: ${err.message}`); + } + await fs.writeJson(techdocsMetadataPath, json); return; }; diff --git a/packages/techdocs-common/src/stages/generate/techdocs.ts b/packages/techdocs-common/src/stages/generate/techdocs.ts index 728cab5a81..8681736815 100644 --- a/packages/techdocs-common/src/stages/generate/techdocs.ts +++ b/packages/techdocs-common/src/stages/generate/techdocs.ts @@ -23,7 +23,7 @@ import { ScmIntegrations, } from '@backstage/integration'; import { - addBuildTimestampMetadata, + createOrUpdateMetadata, getMkdocsYml, patchIndexPreBuild, patchMkdocsYmlPreBuild, @@ -164,9 +164,9 @@ export class TechdocsGenerator implements GeneratorBase { * Post Generate steps */ - // Add build timestamp to techdocs_metadata.json + // Add build timestamp and files to techdocs_metadata.json // Creates techdocs_metadata.json if file does not exist. - await addBuildTimestampMetadata( + await createOrUpdateMetadata( path.join(outputDir, 'techdocs_metadata.json'), childLogger, ); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index 1f49668d35..3e4c4c705b 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -95,6 +95,7 @@ describe('AwsS3Publish', () => { site_name: 'backstage', site_description: 'site_content', etag: 'etag', + build_timestamp: 612741599, }; const directory = getEntityRootDir(entity); @@ -149,21 +150,39 @@ describe('AwsS3Publish', () => { describe('publish', () => { it('should publish a directory', async () => { const publisher = createPublisherFromConfig(); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/component/backstage/404.html', + `default/component/backstage/index.html`, + `default/component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory as well when legacy casing is used', async () => { const publisher = createPublisherFromConfig({ legacyUseCaseSensitiveTripletPaths: true, }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/Component/backstage/404.html', + `default/Component/backstage/index.html`, + `default/Component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory when root path is specified', async () => { const publisher = createPublisherFromConfig({ bucketRootPath: 'backstage-data/techdocs', }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'backstage-data/techdocs/default/component/backstage/404.html', + `backstage-data/techdocs/default/component/backstage/index.html`, + `backstage-data/techdocs/default/component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory when root path is specified and legacy casing is used', async () => { @@ -171,14 +190,26 @@ describe('AwsS3Publish', () => { bucketRootPath: 'backstage-data/techdocs', legacyUseCaseSensitiveTripletPaths: true, }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'backstage-data/techdocs/default/Component/backstage/404.html', + `backstage-data/techdocs/default/Component/backstage/index.html`, + `backstage-data/techdocs/default/Component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory when sse is specified', async () => { const publisher = createPublisherFromConfig({ sse: 'aws:kms', }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/component/backstage/404.html', + 'default/component/backstage/index.html', + 'default/component/backstage/assets/main.css', + ]), + }); }); it('should fail to publish a directory', async () => { diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index abfa8ddd24..ed76edc0cb 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -39,6 +39,7 @@ import { import { PublisherBase, PublishRequest, + PublishResponse, ReadinessResponse, TechDocsMetadata, } from './types'; @@ -214,7 +215,11 @@ export class AwsS3Publish implements PublisherBase { * Upload all the files from the generated `directory` to the S3 bucket. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html */ - async publish({ entity, directory }: PublishRequest): Promise { + async publish({ + entity, + directory, + }: PublishRequest): Promise { + const objects: string[] = []; const useLegacyPathCasing = this.legacyPathCasing; const bucketRootPath = this.bucketRootPath; const sse = this.sse; @@ -263,6 +268,7 @@ export class AwsS3Publish implements PublisherBase { ...(sse && { ServerSideEncryption: sse }), } as aws.S3.PutObjectRequest; + objects.push(params.Key); return this.storageClient.upload(params).promise(); }, absoluteFilesToUpload, @@ -311,6 +317,7 @@ export class AwsS3Publish implements PublisherBase { const errorMessage = `Unable to delete file(s) from AWS S3. ${error}`; this.logger.error(errorMessage); } + return { objects }; } async fetchTechDocsMetadata( diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index a2503bd7b2..e6fd45eed7 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -89,6 +89,7 @@ describe('AzureBlobStoragePublish', () => { site_name: 'backstage', site_description: 'site_content', etag: 'etag', + build_timestamp: 612741599, }; const directory = getEntityRootDir(entity); @@ -154,14 +155,26 @@ describe('AzureBlobStoragePublish', () => { describe('publish', () => { it('should publish a directory', async () => { const publisher = createPublisherFromConfig(); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/component/backstage/404.html', + `default/component/backstage/index.html`, + `default/component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory as well when legacy casing is used', async () => { const publisher = createPublisherFromConfig({ legacyUseCaseSensitiveTripletPaths: true, }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/Component/backstage/404.html', + `default/Component/backstage/index.html`, + `default/Component/backstage/assets/main.css`, + ]), + }); }); it('should fail to publish a directory', async () => { diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index 7a58d92095..b082079be2 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -39,6 +39,7 @@ import { import { PublisherBase, PublishRequest, + PublishResponse, ReadinessResponse, TechDocsMetadata, } from './types'; @@ -156,7 +157,11 @@ export class AzureBlobStoragePublish implements PublisherBase { * Upload all the files from the generated `directory` to the Azure Blob Storage container. * Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html */ - async publish({ entity, directory }: PublishRequest): Promise { + async publish({ + entity, + directory, + }: PublishRequest): Promise { + const objects: string[] = []; const useLegacyPathCasing = this.legacyPathCasing; // First, try to retrieve a list of all individual files currently existing @@ -194,14 +199,14 @@ export class AzureBlobStoragePublish implements PublisherBase { const relativeFilePath = path.normalize( path.relative(directory, absoluteFilePath), ); + const remotePath = getCloudPathForLocalPath( + entity, + relativeFilePath, + useLegacyPathCasing, + ); + objects.push(remotePath); const response = await container - .getBlockBlobClient( - getCloudPathForLocalPath( - entity, - relativeFilePath, - useLegacyPathCasing, - ), - ) + .getBlockBlobClient(remotePath) .uploadFile(absoluteFilePath); if (response._response.status >= 400) { @@ -264,6 +269,8 @@ export class AzureBlobStoragePublish implements PublisherBase { const errorMessage = `Unable to delete file(s) from Azure. ${error}`; this.logger.error(errorMessage); } + + return { objects }; } private download(containerName: string, blobPath: string): Promise { diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index a52fd0f0bd..ed35f2e445 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -88,6 +88,7 @@ describe('GoogleGCSPublish', () => { site_name: 'backstage', site_description: 'site_content', etag: 'etag', + build_timestamp: 612741599, }; const directory = getEntityRootDir(entity); @@ -142,21 +143,39 @@ describe('GoogleGCSPublish', () => { describe('publish', () => { it('should publish a directory', async () => { const publisher = createPublisherFromConfig(); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/component/backstage/404.html', + `default/component/backstage/index.html`, + `default/component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory as well when legacy casing is used', async () => { const publisher = createPublisherFromConfig({ legacyUseCaseSensitiveTripletPaths: true, }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'default/Component/backstage/404.html', + `default/Component/backstage/index.html`, + `default/Component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory when root path is specified', async () => { const publisher = createPublisherFromConfig({ bucketRootPath: 'backstage-data/techdocs', }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'backstage-data/techdocs/default/component/backstage/404.html', + `backstage-data/techdocs/default/component/backstage/index.html`, + `backstage-data/techdocs/default/component/backstage/assets/main.css`, + ]), + }); }); it('should publish a directory when root path is specified and legacy casing is used', async () => { @@ -164,7 +183,13 @@ describe('GoogleGCSPublish', () => { bucketRootPath: 'backstage-data/techdocs', legacyUseCaseSensitiveTripletPaths: true, }); - expect(await publisher.publish({ entity, directory })).toBeUndefined(); + expect(await publisher.publish({ entity, directory })).toMatchObject({ + objects: expect.arrayContaining([ + 'backstage-data/techdocs/default/Component/backstage/404.html', + `backstage-data/techdocs/default/Component/backstage/index.html`, + `backstage-data/techdocs/default/Component/backstage/assets/main.css`, + ]), + }); }); it('should fail to publish a directory', async () => { diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index f072412c20..ba70c36e27 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -36,6 +36,7 @@ import { MigrateWriteStream } from './migrations'; import { PublisherBase, PublishRequest, + PublishResponse, ReadinessResponse, TechDocsMetadata, } from './types'; @@ -145,7 +146,11 @@ export class GoogleGCSPublish implements PublisherBase { * Upload all the files from the generated `directory` to the GCS bucket. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html */ - async publish({ entity, directory }: PublishRequest): Promise { + async publish({ + entity, + directory, + }: PublishRequest): Promise { + const objects: string[] = []; const useLegacyPathCasing = this.legacyPathCasing; const bucket = this.storageClient.bucket(this.bucketName); const bucketRootPath = this.bucketRootPath; @@ -178,14 +183,14 @@ export class GoogleGCSPublish implements PublisherBase { await bulkStorageOperation( async absoluteFilePath => { const relativeFilePath = path.relative(directory, absoluteFilePath); - return await bucket.upload(absoluteFilePath, { - destination: getCloudPathForLocalPath( - entity, - relativeFilePath, - useLegacyPathCasing, - bucketRootPath, - ), - }); + const destination = getCloudPathForLocalPath( + entity, + relativeFilePath, + useLegacyPathCasing, + bucketRootPath, + ); + objects.push(destination); + return await bucket.upload(absoluteFilePath, { destination }); }, absoluteFilesToUpload, { concurrencyLimit: 10 }, @@ -228,6 +233,8 @@ export class GoogleGCSPublish implements PublisherBase { const errorMessage = `Unable to delete file(s) from Google Cloud Storage. ${error}`; this.logger.error(errorMessage); } + + return { objects }; } fetchTechDocsMetadata(entityName: EntityName): Promise { diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index 70b4eb3ff2..9c146d2935 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -98,7 +98,10 @@ export class LocalPublish implements PublisherBase { }; } - publish({ entity, directory }: PublishRequest): Promise { + async publish({ + entity, + directory, + }: PublishRequest): Promise { const entityNamespace = entity.metadata.namespace ?? 'default'; const publishDir = this.staticEntityPathJoin( @@ -112,27 +115,30 @@ export class LocalPublish implements PublisherBase { fs.mkdirSync(publishDir, { recursive: true }); } - return new Promise((resolve, reject) => { - fs.copy(directory, publishDir, err => { - if (err) { - this.logger.debug( - `Failed to copy docs from ${directory} to ${publishDir}`, - ); - reject(err); - } - this.logger.info(`Published site stored at ${publishDir}`); - this.discovery - .getBaseUrl('techdocs') - .then(techdocsApiUrl => { - resolve({ - remoteUrl: `${techdocsApiUrl}/static/docs/${entity.metadata.name}`, - }); - }) - .catch(reason => { - reject(reason); - }); - }); - }); + try { + await fs.copy(directory, publishDir); + this.logger.info(`Published site stored at ${publishDir}`); + } catch (error) { + this.logger.debug( + `Failed to copy docs from ${directory} to ${publishDir}`, + ); + throw error; + } + + // Generate publish response. + const techdocsApiUrl = await this.discovery.getBaseUrl('techdocs'); + const publishedFilePaths = (await getFileTreeRecursively(publishDir)).map( + abs => { + return abs.split(`${staticDocsDir}/`)[1]; + }, + ); + + return { + remoteUrl: `${techdocsApiUrl}/static/docs/${encodeURIComponent( + entity.metadata.name, + )}`, + objects: publishedFilePaths, + }; } async fetchTechDocsMetadata( diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts index c2236fb880..ef06e26cfa 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts @@ -170,7 +170,13 @@ describe('OpenStackSwiftPublish', () => { entity, directory: entityRootDir, }), - ).toBeUndefined(); + ).toMatchObject({ + objects: expect.arrayContaining([ + 'test-namespace/TestKind/test-component-name/404.html', + `test-namespace/TestKind/test-component-name/index.html`, + `test-namespace/TestKind/test-component-name/assets/main.css`, + ]), + }); }); it('should fail to publish a directory', async () => { @@ -241,7 +247,7 @@ describe('OpenStackSwiftPublish', () => { mockFs({ [entityRootDir]: { 'techdocs_metadata.json': - '{"site_name": "backstage", "site_description": "site_content", "etag": "etag"}', + '{"site_name": "backstage", "site_description": "site_content", "etag": "etag", "build_timestamp": 612741599}', }, }); @@ -249,6 +255,7 @@ describe('OpenStackSwiftPublish', () => { site_name: 'backstage', site_description: 'site_content', etag: 'etag', + build_timestamp: 612741599, }; expect( await publisher.fetchTechDocsMetadata(entityNameMock), @@ -263,7 +270,7 @@ describe('OpenStackSwiftPublish', () => { mockFs({ [entityRootDir]: { - 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}`, + 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag', 'build_timestamp': 612741599}`, }, }); @@ -271,6 +278,7 @@ describe('OpenStackSwiftPublish', () => { site_name: 'backstage', site_description: 'site_content', etag: 'etag', + build_timestamp: 612741599, }; expect( await publisher.fetchTechDocsMetadata(entityNameMock), diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index 7b623933e3..62b40f9a76 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -32,6 +32,7 @@ import { import { PublisherBase, PublishRequest, + PublishResponse, ReadinessResponse, TechDocsMetadata, } from './types'; @@ -139,8 +140,13 @@ export class OpenStackSwiftPublish implements PublisherBase { * Upload all the files from the generated `directory` to the OpenStack Swift container. * Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html */ - async publish({ entity, directory }: PublishRequest): Promise { + async publish({ + entity, + directory, + }: PublishRequest): Promise { try { + const objects: string[] = []; + // Note: OpenStack Swift manages creation of parent directories if they do not exist. // So collecting path of only the files is good enough. const allFilesToUpload = await getFileTreeRecursively(directory); @@ -161,6 +167,7 @@ export class OpenStackSwiftPublish implements PublisherBase { // The / delimiter is intentional since it represents the cloud storage and not the local file system. const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; const destination = `${entityRootDir}/${relativeFilePathPosix}`; // Swift container file relative path + objects.push(destination); // Rate limit the concurrent execution of file uploads to batches of 10 (per publish) const uploadFile = limiter(async () => { @@ -178,7 +185,7 @@ export class OpenStackSwiftPublish implements PublisherBase { this.logger.info( `Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, ); - return; + return { objects }; } catch (e) { const errorMessage = `Unable to upload file(s) to OpenStack Swift. ${e}`; this.logger.error(errorMessage); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index 229c853427..1fb301340b 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -32,9 +32,21 @@ export type PublishRequest = { directory: string; }; -/* `remoteUrl` is the URL which serves files from the local publisher's static directory. */ +/** + * Response containing metadata about where files were published and what may + * have been published or updated. + */ export type PublishResponse = { + /** + * The URL which serves files from the local publisher's static directory. + */ remoteUrl?: string; + /** + * The list of objects (specifically their paths) that were published. + * Objects do not have a preceding slash, and match how one would load the + * object over the `/static/docs/*` TechDocs Backend Plugin endpoint. + */ + objects?: string[]; } | void; /** @@ -53,6 +65,8 @@ export type TechDocsMetadata = { site_name: string; site_description: string; etag: string; + build_timestamp: number; + files?: string[]; }; export type MigrateRequest = { diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index aa4fde2043..e3ee7ec0aa 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@asyncapi/react-component": "^0.23.0", + "@asyncapi/react-component": "^1.0.0-next.25", "@backstage/catalog-model": "^0.9.7", "@backstage/core-components": "^0.7.6", "@backstage/core-plugin-api": "^0.2.2", diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx index cb89476c03..c9f979edad 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.test.tsx @@ -18,6 +18,15 @@ import { renderInTestApp } from '@backstage/test-utils'; import React from 'react'; import { AsyncApiDefinition } from './AsyncApiDefinition'; +jest.mock('use-resize-observer', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })), +})); + describe('', () => { it('renders asyncapi spec', async () => { const definition = ` @@ -45,15 +54,7 @@ components: expect(getByText(/Account Service/i)).toBeInTheDocument(); expect(getByText(/user\/signedup/i)).toBeInTheDocument(); - expect(getByText(/UserSignedUp/i)).toBeInTheDocument(); - expect(getAllByText(/displayName/i)).toHaveLength(4); - }); - - it('renders error if definition is missing', async () => { - const { getByText } = await renderInTestApp( - , - ); - expect(getByText(/Error/i)).toBeInTheDocument(); - expect(getByText(/Document can't be null or falsey/i)).toBeInTheDocument(); + expect(getAllByText(/UserSignedUp/i)).toHaveLength(2); + expect(getAllByText(/displayName/i)).toHaveLength(3); }); }); diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx index 1c993f67d7..3f9375921d 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinition.tsx @@ -15,23 +15,72 @@ */ import AsyncApi from '@asyncapi/react-component'; -import '@asyncapi/react-component/lib/styles/fiori.css'; -import { alpha, makeStyles } from '@material-ui/core/styles'; +import '@asyncapi/react-component/styles/default.css'; +import { makeStyles, alpha, darken } from '@material-ui/core/styles'; +import { BackstageTheme } from '@backstage/theme'; import React from 'react'; +import { useTheme } from '@material-ui/core'; -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles((theme: BackstageTheme) => ({ root: { - '& .asyncapi': { - 'font-family': 'inherit', + fontFamily: 'inherit', + '& .bg-white': { background: 'none', }, - '& h2': { - ...theme.typography.h6, + '& .text-4xl': { + ...theme.typography.h3, }, - '& .text-teal': { - color: theme.palette.primary.main, + ' & h2': { + ...theme.typography.h4, + }, + '& .border': { + borderColor: alpha(theme.palette.border, 0.1), + }, + '& .min-w-min': { + minWidth: 'fit-content', + }, + '& .examples': { + padding: '1rem', + }, + '& .bg-teal-500': { + backgroundColor: theme.palette.status.ok, + }, + '& .bg-blue-500': { + backgroundColor: theme.palette.info.main, + }, + '& .bg-blue-400': { + backgroundColor: theme.palette.info.light, + }, + '& .bg-indigo-400': { + backgroundColor: theme.palette.warning.main, + }, + '& .text-teal-50': { + color: theme.palette.status.ok, + }, + '& .text-red-600': { + color: theme.palette.error.main, + }, + '& .text-orange-600': { + color: theme.palette.warning.main, + }, + '& .text-teal-500': { + color: theme.palette.status.ok, + }, + '& .text-blue-500': { + color: theme.palette.info.main, + }, + '& .-rotate-90': { + '--tw-rotate': '0deg', }, '& button': { + ...theme.typography.button, + borderRadius: theme.shape.borderRadius, + color: theme.palette.primary.main, + }, + '& a': { + color: theme.palette.link, + }, + '& a.no-underline': { ...theme.typography.button, background: 'none', boxSizing: 'border-box', @@ -48,84 +97,47 @@ const useStyles = makeStyles(theme => ({ border: `1px solid ${alpha(theme.palette.primary.main, 0.5)}`, '&:hover': { textDecoration: 'none', - '&.Mui-disabled': { - backgroundColor: 'transparent', - }, border: `1px solid ${theme.palette.primary.main}`, backgroundColor: alpha( theme.palette.primary.main, theme.palette.action.hoverOpacity, ), - // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { - backgroundColor: 'transparent', - }, - }, - '&.Mui-disabled': { - color: theme.palette.action.disabled, }, }, - '& .asyncapi__collapse-button:hover': { - color: theme.palette.primary.main, - }, - '& button.asyncapi__toggle-button': { - 'min-width': 'inherit', - }, - '& .asyncapi__info-list li': { - 'border-color': theme.palette.primary.main, - '&:hover': { - color: theme.palette.text.primary, - 'border-color': theme.palette.primary.main, - 'background-color': theme.palette.primary.main, - }, - }, - '& .asyncapi__info-list li a': { - color: theme.palette.primary.main, - '&:hover': { + '& li.no-underline': { + '& a': { + textDecoration: 'none', color: theme.palette.getContrastText(theme.palette.primary.main), }, }, - '& .asyncapi__enum': { - color: theme.palette.secondary.main, + }, + dark: { + '& svg': { + fill: theme.palette.text.primary, }, - '& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__channel-operations-list .asyncapi__messages-list-item .asyncapi__message, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': - { - 'background-color': 'inherit', - }, - '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header, .asyncapi__channel-operation-message-header, .asyncapi__message-header, .asyncapi__message-header-title, .asyncapi__message-header-title > h3, .asyncapi__bindings, .asyncapi__bindings-header, .asyncapi__bindings-header > h4': - { - 'background-color': 'inherit', + '& .prose': { + color: theme.palette.text.secondary, + '& h3': { color: theme.palette.text.primary, }, - '& .asyncapi__additional-properties-notice': { - color: theme.palette.text.hint, }, - '& .asyncapi__code, .asyncapi__code-pre': { - background: theme.palette.background.default, + '& .bg-gray-100, .bg-gray-200': { + backgroundColor: theme.palette.background.default, }, - '& .asyncapi__schema-example-header-title': { - color: theme.palette.text.secondary, + '& .text-gray-600': { + color: theme.palette.grey['50'], }, - '& .asyncapi__message-headers-header, .asyncapi__message-payload-header, .asyncapi__server-variables-header, .asyncapi__server-security-header': - { - 'background-color': 'inherit', - color: theme.palette.text.secondary, + '& .text-gray-700': { + color: theme.palette.grey['100'], + }, + '& .panel--right': { + background: darken(theme.palette.navigation.background, 0.1), + }, + '& .examples': { + backgroundColor: darken(theme.palette.navigation.background, 0.1), + '& pre': { + backgroundColor: darken(theme.palette.background.default, 0.2), }, - '& .asyncapi__table-header': { - background: theme.palette.background.default, - }, - '& .asyncapi__table-body': { - color: theme.palette.text.primary, - }, - '& .asyncapi__server-security-flow': { - background: theme.palette.background.default, - border: 'none', - }, - '& .asyncapi__server-security-flows-list a': { - color: theme.palette.primary.main, - }, - '& .asyncapi__table-row--nested': { - color: theme.palette.text.secondary, }, }, })); @@ -134,11 +146,15 @@ type Props = { definition: string; }; -export const AsyncApiDefinition = ({ definition }: Props) => { +export const AsyncApiDefinition = ({ definition }: Props): JSX.Element => { const classes = useStyles(); + const theme = useTheme(); + const classNames = `${classes.root} ${ + theme.palette.type === 'dark' ? classes.dark : '' + }`; return ( -
+
); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 12931c297f..c4b1e79262 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -27,7 +27,7 @@ import FormHelperText from '@material-ui/core/FormHelperText'; import { useApi } from '@backstage/core-plugin-api'; import { Progress } from '@backstage/core-components'; -function splitFormData(url: string | undefined, allowedOwners: string[]) { +function splitFormData(url: string | undefined, allowedOwners?: string[]) { let host = undefined; let owner = undefined; let repo = undefined; @@ -39,10 +39,7 @@ function splitFormData(url: string | undefined, allowedOwners: string[]) { if (url) { const parsed = new URL(`https://${url}`); host = parsed.host; - owner = - parsed.searchParams.get('owner') || allowedOwners - ? allowedOwners[0] - : undefined; + owner = parsed.searchParams.get('owner') || allowedOwners?.[0]; repo = parsed.searchParams.get('repo') || undefined; // This is azure dev ops specific. not used for any other provider. organization = parsed.searchParams.get('organization') || undefined; diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md index f9be757e6a..9736555687 100644 --- a/plugins/techdocs-backend/api-report.md +++ b/plugins/techdocs-backend/api-report.md @@ -10,6 +10,7 @@ import express from 'express'; import { GeneratorBuilder } from '@backstage/techdocs-common'; import { Knex } from 'knex'; import { Logger as Logger_2 } from 'winston'; +import { PluginCacheManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PreparerBuilder } from '@backstage/techdocs-common'; import { PublisherBase } from '@backstage/techdocs-common'; diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 9e563137bd..8d531e0536 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -226,6 +226,32 @@ export interface Config { }; }; + /** + * @example http://localhost:7007/api/techdocs + * Techdocs cache information + */ + cache?: { + /** + * The cache time-to-live for TechDocs sites (in milliseconds). Set this + * to a non-zero value to cache TechDocs sites and assets as they are + * read from storage. + * + * Note: you must also configure `backend.cache` appropriately as well, + * and to pass a PluginCacheManager instance to TechDocs Backend's + * createRouter method in your backend. + */ + ttl: number; + + /** + * The time (in milliseconds) that the TechDocs backend will wait for + * a cache service to respond before continuing on as though the cached + * object was not found (e.g. when the cache sercice is unavailable). + * + * Defaults to 1000 milliseconds. + */ + readTimeout?: number; + }; + /** * @example http://localhost:7007/api/techdocs * @visibility frontend diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 67ff25166b..b2e7fee261 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -40,6 +40,7 @@ "@backstage/search-common": "^0.2.1", "@backstage/techdocs-common": "^0.10.8", "@types/express": "^4.17.6", + "cross-fetch": "^3.0.6", "dockerode": "^3.3.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts index 821b3478a0..66eb52c1aa 100644 --- a/plugins/techdocs-backend/src/DocsBuilder/builder.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts @@ -36,6 +36,7 @@ import path from 'path'; import { Writable } from 'stream'; import { Logger } from 'winston'; import { BuildMetadataStorage } from './BuildMetadataStorage'; +import { TechDocsCache } from '../cache'; type DocsBuilderArguments = { preparers: PreparerBuilder; @@ -46,6 +47,7 @@ type DocsBuilderArguments = { config: Config; scmIntegrations: ScmIntegrationRegistry; logStream?: Writable; + cache?: TechDocsCache; }; export class DocsBuilder { @@ -57,6 +59,7 @@ export class DocsBuilder { private config: Config; private scmIntegrations: ScmIntegrationRegistry; private logStream: Writable | undefined; + private cache?: TechDocsCache; constructor({ preparers, @@ -67,6 +70,7 @@ export class DocsBuilder { config, scmIntegrations, logStream, + cache, }: DocsBuilderArguments) { this.preparer = preparers.get(entity); this.generator = generators.get(entity); @@ -76,6 +80,7 @@ export class DocsBuilder { this.config = config; this.scmIntegrations = scmIntegrations; this.logStream = logStream; + this.cache = cache; } /** @@ -210,11 +215,19 @@ export class DocsBuilder { )}`, ); - await this.publisher.publish({ + const published = await this.publisher.publish({ entity: this.entity, directory: outputDir, }); + // Invalidate the cache for any published objects. + if (this.cache && published && published?.objects?.length) { + this.logger.debug( + `Invalidating ${published.objects.length} cache objects`, + ); + await this.cache.invalidateMultiple(published.objects); + } + try { // Not a blocker hence no need to await this. fs.remove(outputDir); diff --git a/plugins/techdocs-backend/src/cache/TechDocsCache.test.ts b/plugins/techdocs-backend/src/cache/TechDocsCache.test.ts new file mode 100644 index 0000000000..17613e1b58 --- /dev/null +++ b/plugins/techdocs-backend/src/cache/TechDocsCache.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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. + */ + +import { CacheClient, getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { CacheInvalidationError, TechDocsCache } from './TechDocsCache'; + +const cached = (str: string): string => { + return Buffer.from(str).toString('base64'); +}; + +describe('TechDocsCache', () => { + let CacheUnderTest: TechDocsCache; + let MockClient: jest.Mocked; + + beforeEach(() => { + MockClient = { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + CacheUnderTest = TechDocsCache.fromConfig(new ConfigReader({}), { + cache: MockClient, + logger: getVoidLogger(), + }); + }); + + describe('get', () => { + it('returns undefined if no response', async () => { + const expectedPath = 'some/index.html'; + MockClient.get.mockResolvedValueOnce(undefined); + + const actual = await CacheUnderTest.get(expectedPath); + expect(MockClient.get).toHaveBeenCalledWith(expectedPath); + expect(actual).toBe(undefined); + }); + + it('returns undefined if cache get throws', async () => { + const expectedPath = 'some/index.html'; + MockClient.get.mockRejectedValueOnce(new Error()); + + const actual = await CacheUnderTest.get(expectedPath); + expect(actual).toBe(undefined); + }); + + it('returns undefined if no response after 1s by default', async () => { + const expectedPath = 'some/index.html'; + MockClient.get.mockImplementationOnce(() => { + return new Promise(resolve => { + setTimeout(() => resolve(cached('value')), 1500); + }); + }); + const actual = await CacheUnderTest.get(expectedPath); + expect(actual).toBe(undefined); + }); + + it('returns undefined if no response after configured readTimeout', async () => { + const expectedPath = 'some/index.html'; + MockClient.get.mockImplementationOnce(() => { + return new Promise(resolve => { + setTimeout(() => resolve(cached('value')), 20); + }); + }); + + CacheUnderTest = TechDocsCache.fromConfig( + new ConfigReader({ + techdocs: { cache: { readTimeout: 10 } }, + }), + { + cache: MockClient, + logger: getVoidLogger(), + }, + ); + + const actual = await CacheUnderTest.get(expectedPath); + expect(actual).toBe(undefined); + }); + + it('returns data if cache get returns it', async () => { + const expectedPath = 'some/index.html'; + MockClient.get.mockResolvedValueOnce(cached('expected value')); + + const actual = await CacheUnderTest.get(expectedPath); + expect(actual?.toString()).toBe('expected value'); + }); + }); + + describe('set', () => { + it('sets a base64-encoded string', async () => { + const expectedPath = 'some/index.html'; + MockClient.set.mockResolvedValueOnce(undefined); + + await CacheUnderTest.set(expectedPath, Buffer.from('some data')); + expect(MockClient.set).toHaveBeenCalledWith( + expectedPath, + cached('some data'), + ); + }); + + it('does not throw if client throws', () => { + MockClient.set.mockRejectedValueOnce(new Error()); + expect(() => CacheUnderTest.set('i.html', Buffer.from(''))).not.toThrow(); + }); + }); + + describe('invalidate', () => { + it('calls delete on client', async () => { + const expectedPath = 'some/index.html'; + MockClient.delete.mockResolvedValueOnce(undefined); + + await CacheUnderTest.invalidate(expectedPath); + expect(MockClient.delete).toHaveBeenCalledWith(expectedPath); + }); + }); + + describe('invalidateMultiple', () => { + it('calls delete once per given path', async () => { + const expectedPaths = ['one/index.html', 'two/index.html']; + MockClient.delete.mockResolvedValue(undefined); + + await CacheUnderTest.invalidateMultiple(expectedPaths); + expect(MockClient.delete).toHaveBeenNthCalledWith(1, expectedPaths[0]); + expect(MockClient.delete).toHaveBeenNthCalledWith(2, expectedPaths[1]); + }); + + it('returns an array of as many paths provided', async () => { + const expectedPaths = ['one/index.html', 'two/index.html']; + MockClient.delete.mockResolvedValue(undefined); + + const actual = await CacheUnderTest.invalidateMultiple(expectedPaths); + expect(actual.length).toBe(2); + }); + + it('calls delete on all paths even if the first rejects', async () => { + const expectedPaths = ['one/index.html', 'two/index.html']; + MockClient.delete.mockRejectedValueOnce(new Error()); + MockClient.delete.mockResolvedValueOnce(undefined); + + await expect( + CacheUnderTest.invalidateMultiple(expectedPaths), + ).rejects.toThrowError(CacheInvalidationError); + expect(MockClient.delete).toHaveBeenCalledTimes(2); + }); + + it('rejects with invalidations error response', async () => { + const expectedPaths = ['one/index.html', 'two/index.html']; + MockClient.delete.mockResolvedValueOnce(undefined); + MockClient.delete.mockRejectedValueOnce(new Error()); + + await expect( + CacheUnderTest.invalidateMultiple.bind(CacheUnderTest, expectedPaths), + ).rejects.toThrow(CacheInvalidationError); + }); + }); +}); diff --git a/plugins/techdocs-backend/src/cache/TechDocsCache.ts b/plugins/techdocs-backend/src/cache/TechDocsCache.ts new file mode 100644 index 0000000000..807d6ca87b --- /dev/null +++ b/plugins/techdocs-backend/src/cache/TechDocsCache.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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. + */ +import { CacheClient } from '@backstage/backend-common'; +import { assertError, CustomErrorBase } from '@backstage/errors'; +import { Config } from '@backstage/config'; +import { Logger } from 'winston'; + +export class CacheInvalidationError extends CustomErrorBase {} + +export class TechDocsCache { + protected readonly cache: CacheClient; + protected readonly logger: Logger; + protected readonly readTimeout: number; + + private constructor({ + cache, + logger, + readTimeout, + }: { + cache: CacheClient; + logger: Logger; + readTimeout: number; + }) { + this.cache = cache; + this.logger = logger; + this.readTimeout = readTimeout; + } + + static fromConfig( + config: Config, + { cache, logger }: { cache: CacheClient; logger: Logger }, + ) { + const timeout = config.getOptionalNumber('techdocs.cache.readTimeout'); + const readTimeout = timeout === undefined ? 1000 : timeout; + return new TechDocsCache({ cache, logger, readTimeout }); + } + + async get(path: string): Promise { + try { + // Promise.race ensures we don't hang the client for long if the cache is + // temporarily unreachable. + const response = (await Promise.race([ + this.cache.get(path), + new Promise(cancelAfter => setTimeout(cancelAfter, this.readTimeout)), + ])) as string | undefined; + + if (response !== undefined) { + this.logger.debug(`Cache hit: ${path}`); + return Buffer.from(response, 'base64'); + } + + this.logger.debug(`Cache miss: ${path}`); + return response; + } catch (e) { + assertError(e); + this.logger.warn(`Error getting cache entry ${path}: ${e.message}`); + this.logger.debug(e.stack); + return undefined; + } + } + + async set(path: string, data: Buffer): Promise { + this.logger.debug(`Writing cache entry for ${path}`); + this.cache + .set(path, data.toString('base64')) + .catch(e => this.logger.error('write error', e)); + } + + async invalidate(path: string): Promise { + return this.cache.delete(path); + } + + async invalidateMultiple( + paths: string[], + ): Promise[]> { + const settled = await Promise.allSettled( + paths.map(path => this.cache.delete(path)), + ); + const rejected = settled.filter( + s => s.status === 'rejected', + ) as PromiseRejectedResult[]; + + if (rejected.length) { + throw new CacheInvalidationError( + 'TechDocs cache invalidation error', + rejected, + ); + } + + return settled; + } +} diff --git a/plugins/techdocs-backend/src/cache/cacheMiddleware.test.ts b/plugins/techdocs-backend/src/cache/cacheMiddleware.test.ts new file mode 100644 index 0000000000..312674a5bf --- /dev/null +++ b/plugins/techdocs-backend/src/cache/cacheMiddleware.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; +import { createCacheMiddleware, TechDocsCache } from '.'; + +/** + * Mocks cached HTTP response. + */ +const getMockHttpResponseFor = (content: string): Buffer => { + return Buffer.concat([ + Buffer.from(`HTTP/1.1 200 OK +Content-Type: text/plain; charset=utf-8 +Accept-Ranges: bytes +Cache-Control: public, max-age=0 +Last-Modified: Sat, 1 Jul 2021 12:00:00 GMT +Date: Sat, 1 Jul 2021 12:00:00 GMT +Connection: close +Content-Length: ${content.length}\n\n`), + Buffer.from(content), + ]); +}; + +/** + * Wait for the socket to close. Works because, above, we set connection: close + */ +const waitForSocketClose = () => new Promise(resolve => setTimeout(resolve, 0)); + +describe('createCacheMiddleware', () => { + let cache: jest.Mocked; + let app: express.Express; + + beforeEach(async () => { + cache = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + invalidate: jest.fn().mockResolvedValue(undefined), + invalidateMultiple: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + const router = await createCacheMiddleware({ + logger: getVoidLogger(), + cache, + }); + app = express().use(router); + app.use((req, res, next) => { + // By default, send cacheable content. + if (req.path !== '/static/docs/error.png') { + res.send('default-response'); + } else { + next(new Error()); + } + }); + }); + + describe('middleware', () => { + it('does not apply to non-static/docs paths', async () => { + await request(app) + .get('/static/not-docs') + .expect(200, 'default-response'); + + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('responds with cached response', async () => { + cache.get.mockResolvedValueOnce(getMockHttpResponseFor('xyz')); + + await request(app).get('/static/docs/foo.html').expect(200, 'xyz'); + + await waitForSocketClose(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('sets cache when content is cacheable', async () => { + const expectedPath = 'default/api/xyz/index.html'; + await request(app) + .get(`/static/docs/${expectedPath}`) + .expect(200, 'default-response'); + + await waitForSocketClose(); + expect(cache.set).toHaveBeenCalled(); + + const [actualPath, actualBuffer] = (cache.set as jest.Mock).mock.calls[0]; + expect(actualPath).toBe(expectedPath); + expect(actualBuffer.toString()).toContain('default-response'); + }); + + it('does not set cache on error', async () => { + await request(app).get('/static/docs/error.png').expect(500); + + await waitForSocketClose(); + expect(cache.set).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/techdocs-backend/src/cache/cacheMiddleware.ts b/plugins/techdocs-backend/src/cache/cacheMiddleware.ts new file mode 100644 index 0000000000..cb59681304 --- /dev/null +++ b/plugins/techdocs-backend/src/cache/cacheMiddleware.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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. + */ +import { Router } from 'express'; +import router from 'express-promise-router'; +import { Logger } from 'winston'; +import { TechDocsCache } from './TechDocsCache'; + +type CacheMiddlewareOptions = { + cache: TechDocsCache; + logger: Logger; +}; + +type ErrorCallback = (err?: Error) => void; + +export const createCacheMiddleware = ({ + cache, +}: CacheMiddlewareOptions): Router => { + const cacheMiddleware = router(); + + // Middleware that, through socket monkey patching, captures responses as + // they're sent over /static/docs/* and caches them. Subsequent requests are + // loaded from cache. Cache key is the object's path (after `/static/docs/`). + cacheMiddleware.use(async (req, res, next) => { + const socket = res.socket; + const isCacheable = req.path.startsWith('/static/docs/'); + + // Continue early if this is non-cacheable, or there's no socket. + if (!isCacheable || !socket) { + next(); + return; + } + + // Make concrete references to these things. + const reqPath = decodeURI(req.path.match(/\/static\/docs\/(.*)$/)![1]); + const realEnd = socket.end.bind(socket); + const realWrite = socket.write.bind(socket); + let writeToCache = true; + const chunks: Buffer[] = []; + + // Monkey-patch the response's socket to keep track of chunks as they are + // written over the wire. + socket.write = ( + data: string | Uint8Array, + encoding?: BufferEncoding | ErrorCallback, + callback?: ErrorCallback, + ) => { + chunks.push(Buffer.from(data)); + if (typeof encoding === 'function') { + return realWrite(data, encoding); + } + return realWrite(data, encoding, callback); + }; + + // When a socket is closed, if there were no errors and the data written + // over the socket should be cached, cache it! + socket.on('close', async hadError => { + const content = Buffer.concat(chunks); + const head = content.toString('utf8', 0, 12); + if (writeToCache && !hadError && head.match(/HTTP\/\d\.\d 200/)) { + await cache.set(reqPath, content); + } + }); + + // Attempt to retrieve data from the cache. + const cached = await cache.get(reqPath); + + // If there is a cache hit, write it out on the socket, ensure we don't re- + // cache the data, and prevent going back to canonical storage by never + // calling next(). + if (cached) { + writeToCache = false; + realEnd(cached); + return; + } + + // No data retrieved from cache: allow retrieval from canonical storage. + next(); + }); + + return cacheMiddleware; +}; diff --git a/plugins/techdocs-backend/src/cache/index.ts b/plugins/techdocs-backend/src/cache/index.ts new file mode 100644 index 0000000000..751d8e8625 --- /dev/null +++ b/plugins/techdocs-backend/src/cache/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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. + */ +export { createCacheMiddleware } from './cacheMiddleware'; +export { TechDocsCache } from './TechDocsCache'; diff --git a/plugins/techdocs-backend/src/service/DocsSynchronizer.test.ts b/plugins/techdocs-backend/src/service/DocsSynchronizer.test.ts index ac60f3da3a..1eb8c8f56f 100644 --- a/plugins/techdocs-backend/src/service/DocsSynchronizer.test.ts +++ b/plugins/techdocs-backend/src/service/DocsSynchronizer.test.ts @@ -25,11 +25,25 @@ import { PreparerBuilder, PublisherBase, } from '@backstage/techdocs-common'; +import { TechDocsCache } from '../cache'; import { DocsBuilder, shouldCheckForUpdate } from '../DocsBuilder'; import { DocsSynchronizer, DocsSynchronizerSyncOpts } from './DocsSynchronizer'; jest.mock('../DocsBuilder'); +jest.mock('cross-fetch', () => ({ + __esModule: true, + default: async () => { + return { + json: async () => { + return { + build_timestamp: 123, + }; + }, + }; + }, +})); + const MockedDocsBuilder = DocsBuilder as jest.MockedClass; describe('DocsSynchronizer', () => { @@ -52,6 +66,12 @@ describe('DocsSynchronizer', () => { getBaseUrl: jest.fn(), getExternalBaseUrl: jest.fn(), }; + const cache: jest.Mocked = { + get: jest.fn(), + set: jest.fn(), + invalidate: jest.fn(), + invalidateMultiple: jest.fn(), + } as unknown as jest.Mocked; let docsSynchronizer: DocsSynchronizer; const mockResponseHandler: jest.Mocked = { @@ -71,6 +91,7 @@ describe('DocsSynchronizer', () => { config: new ConfigReader({}), logger: getVoidLogger(), scmIntegrations: ScmIntegrations.fromConfig(new ConfigReader({})), + cache, }); }); @@ -193,4 +214,112 @@ describe('DocsSynchronizer', () => { expect(mockResponseHandler.error).toBeCalledWith(error); }); }); + + describe('doCacheSync', () => { + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + uid: '0', + name: 'test', + namespace: 'default', + }, + }; + + it('should not check metadata too often', async () => { + (shouldCheckForUpdate as jest.Mock).mockReturnValue(false); + + await docsSynchronizer.doCacheSync({ + responseHandler: mockResponseHandler, + discovery, + token: undefined, + entity, + }); + + expect(mockResponseHandler.finish).toBeCalledWith({ updated: false }); + expect(shouldCheckForUpdate).toBeCalledTimes(1); + }); + + it('should do nothing if source/cached metadata matches', async () => { + (shouldCheckForUpdate as jest.Mock).mockReturnValue(true); + (publisher.fetchTechDocsMetadata as jest.Mock).mockResolvedValue({ + build_timestamp: 123, + }); + + await docsSynchronizer.doCacheSync({ + responseHandler: mockResponseHandler, + discovery, + token: undefined, + entity, + }); + + expect(mockResponseHandler.finish).toBeCalledWith({ updated: false }); + }); + + it('should invalidate expected files when source/cached metadata differ', async () => { + (shouldCheckForUpdate as jest.Mock).mockReturnValue(true); + (publisher.fetchTechDocsMetadata as jest.Mock).mockResolvedValue({ + build_timestamp: 456, + files: ['index.html'], + }); + + await docsSynchronizer.doCacheSync({ + responseHandler: mockResponseHandler, + discovery, + token: undefined, + entity, + }); + + expect(mockResponseHandler.finish).toBeCalledWith({ updated: true }); + expect(cache.invalidateMultiple).toHaveBeenCalledWith([ + 'default/component/test/index.html', + ]); + }); + + it('should invalidate expected files when source/cached metadata differ with legacy casing', async () => { + (shouldCheckForUpdate as jest.Mock).mockReturnValue(true); + (publisher.fetchTechDocsMetadata as jest.Mock).mockResolvedValue({ + build_timestamp: 456, + files: ['index.html'], + }); + + const docsSynchronizerWithLegacy = new DocsSynchronizer({ + publisher, + config: new ConfigReader({ + techdocs: { legacyUseCaseSensitiveTripletPaths: true }, + }), + logger: getVoidLogger(), + scmIntegrations: ScmIntegrations.fromConfig(new ConfigReader({})), + cache, + }); + + await docsSynchronizerWithLegacy.doCacheSync({ + responseHandler: mockResponseHandler, + discovery, + token: undefined, + entity, + }); + + expect(mockResponseHandler.finish).toBeCalledWith({ updated: true }); + expect(cache.invalidateMultiple).toHaveBeenCalledWith([ + 'default/Component/test/index.html', + ]); + }); + + it('should gracefully handle errors', async () => { + (shouldCheckForUpdate as jest.Mock).mockReturnValue(true); + (publisher.fetchTechDocsMetadata as jest.Mock).mockRejectedValue( + new Error(), + ); + + await docsSynchronizer.doCacheSync({ + responseHandler: mockResponseHandler, + discovery, + token: undefined, + entity, + }); + + expect(mockResponseHandler.finish).toBeCalledWith({ updated: false }); + }); + }); }); diff --git a/plugins/techdocs-backend/src/service/DocsSynchronizer.ts b/plugins/techdocs-backend/src/service/DocsSynchronizer.ts index 6f977dcb47..4d407ab54a 100644 --- a/plugins/techdocs-backend/src/service/DocsSynchronizer.ts +++ b/plugins/techdocs-backend/src/service/DocsSynchronizer.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { assertError, NotFoundError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; @@ -23,9 +24,15 @@ import { PreparerBuilder, PublisherBase, } from '@backstage/techdocs-common'; +import fetch from 'cross-fetch'; import { PassThrough } from 'stream'; import * as winston from 'winston'; -import { DocsBuilder, shouldCheckForUpdate } from '../DocsBuilder'; +import { TechDocsCache } from '../cache'; +import { + BuildMetadataStorage, + DocsBuilder, + shouldCheckForUpdate, +} from '../DocsBuilder'; export type DocsSynchronizerSyncOpts = { log: (message: string) => void; @@ -38,22 +45,26 @@ export class DocsSynchronizer { private readonly logger: winston.Logger; private readonly config: Config; private readonly scmIntegrations: ScmIntegrationRegistry; + private readonly cache: TechDocsCache | undefined; constructor({ publisher, logger, config, scmIntegrations, + cache, }: { publisher: PublisherBase; logger: winston.Logger; config: Config; scmIntegrations: ScmIntegrationRegistry; + cache: TechDocsCache | undefined; }) { this.config = config; this.logger = logger; this.publisher = publisher; this.scmIntegrations = scmIntegrations; + this.cache = cache; } async doSync({ @@ -104,6 +115,7 @@ export class DocsSynchronizer { config: this.config, scmIntegrations: this.scmIntegrations, logStream, + cache: this.cache, }); const updated = await docsBuilder.build(); @@ -145,4 +157,76 @@ export class DocsSynchronizer { finish({ updated: true }); } + + async doCacheSync({ + responseHandler: { finish }, + discovery, + token, + entity, + }: { + responseHandler: DocsSynchronizerSyncOpts; + discovery: PluginEndpointDiscovery; + token: string | undefined; + entity: Entity; + }) { + // Check if the last update check was too recent. + if (!shouldCheckForUpdate(entity.metadata.uid!) || !this.cache) { + finish({ updated: false }); + return; + } + + // Fetch techdocs_metadata.json from the publisher and from cache. + const baseUrl = await discovery.getBaseUrl('techdocs'); + const namespace = entity.metadata?.namespace || ENTITY_DEFAULT_NAMESPACE; + const kind = entity.kind; + const name = entity.metadata.name; + const legacyPathCasing = + this.config.getOptionalBoolean( + 'techdocs.legacyUseCaseSensitiveTripletPaths', + ) || false; + const tripletPath = `${namespace}/${kind}/${name}`; + const entityTripletPath = `${ + legacyPathCasing ? tripletPath : tripletPath.toLocaleLowerCase('en-US') + }`; + try { + const [sourceMetadata, cachedMetadata] = await Promise.all([ + this.publisher.fetchTechDocsMetadata({ namespace, kind, name }), + fetch( + `${baseUrl}/static/docs/${entityTripletPath}/techdocs_metadata.json`, + { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, + ).then( + f => + f.json().catch(() => undefined) as ReturnType< + PublisherBase['fetchTechDocsMetadata'] + >, + ), + ]); + + // If build timestamps differ, merge their files[] lists and invalidate all objects. + if (sourceMetadata.build_timestamp !== cachedMetadata.build_timestamp) { + const files = [ + ...new Set([ + ...(sourceMetadata.files || []), + ...(cachedMetadata.files || []), + ]), + ].map(f => `${entityTripletPath}/${f}`); + await this.cache.invalidateMultiple(files); + finish({ updated: true }); + } else { + finish({ updated: false }); + } + } catch (e) { + assertError(e); + // In case of error, log and allow the user to go about their business. + this.logger.error( + `Error syncing cache for ${entityTripletPath}: ${e.message}`, + ); + finish({ updated: false }); + } finally { + // Update the last check time for the entity + new BuildMetadataStorage(entity.metadata.uid!).setLastUpdated(); + } + } } diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index d53a2faed4..8f48342683 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -13,7 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { + PluginEndpointDiscovery, + PluginCacheManager, +} from '@backstage/backend-common'; import { CatalogClient } from '@backstage/catalog-client'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; @@ -31,6 +34,7 @@ import { Knex } from 'knex'; import { Logger } from 'winston'; import { ScmIntegrations } from '@backstage/integration'; import { DocsSynchronizer, DocsSynchronizerSyncOpts } from './DocsSynchronizer'; +import { createCacheMiddleware, TechDocsCache } from '../cache'; /** * All of the required dependencies for running TechDocs in the "out-of-the-box" @@ -44,6 +48,7 @@ type OutOfTheBoxDeploymentOptions = { discovery: PluginEndpointDiscovery; database?: Knex; // TODO: Make database required when we're implementing database stuff. config: Config; + cache?: PluginCacheManager; }; /** @@ -80,12 +85,22 @@ export async function createRouter( const router = Router(); const { publisher, config, logger, discovery } = options; const catalogClient = new CatalogClient({ discoveryApi: discovery }); + + // Set up a cache client if configured. + let cache: TechDocsCache | undefined; + const defaultTtl = config.getOptionalNumber('techdocs.cache.ttl'); + if (isOutOfTheBoxOption(options) && options.cache && defaultTtl) { + const cacheClient = options.cache.getClient({ defaultTtl }); + cache = TechDocsCache.fromConfig(config, { cache: cacheClient, logger }); + } + const scmIntegrations = ScmIntegrations.fromConfig(config); const docsSynchronizer = new DocsSynchronizer({ publisher, logger, config, scmIntegrations, + cache, }); router.get('/metadata/techdocs/:namespace/:kind/:name', async (req, res) => { @@ -175,6 +190,17 @@ export async function createRouter( // If set to 'external', it will assume that an external process (e.g. CI/CD pipeline // of the repository) is responsible for building and publishing documentation to the storage provider if (config.getString('techdocs.builder') !== 'local') { + // However, if caching is enabled, take the opportunity to check and + // invalidate stale cache entries. + if (cache) { + await docsSynchronizer.doCacheSync({ + responseHandler, + discovery, + token, + entity, + }); + return; + } responseHandler.finish({ updated: false }); return; } @@ -199,6 +225,11 @@ export async function createRouter( ); }); + // If a cache manager was provided, attach the cache middleware. + if (cache) { + router.use(createCacheMiddleware({ logger, cache })); + } + // Route middleware which serves files from the storage set in the publisher. router.use('/static/docs', publisher.docsRouter()); diff --git a/plugins/xcmetrics/src/api/types.ts b/plugins/xcmetrics/src/api/types.ts index 44f27872ca..6c7d133259 100644 --- a/plugins/xcmetrics/src/api/types.ts +++ b/plugins/xcmetrics/src/api/types.ts @@ -163,7 +163,7 @@ export type Xcode = { export type BuildResponse = { build: Build; targets: Target[]; - xcode: Xcode; + xcode?: Xcode; // Can be undefined if XCMetrics version < v0.0.8 }; export type BuildFilters = { diff --git a/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx b/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx index 7083d890d6..c26c101143 100644 --- a/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx +++ b/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.test.tsx @@ -51,6 +51,20 @@ describe('BuildDetails', () => { ).toBeInTheDocument(); expect(rendered.getByText(client.mockBuild.schema)).toBeInTheDocument(); }); + + it('should render if xcode data is not present', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect( + rendered.getByText('Xcode').parentNode?.childNodes[1].textContent, + ).toEqual('Unknown'); + }); }); describe('BuildDetails with request', () => { diff --git a/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.tsx b/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.tsx index 1d77bf7a7a..8732bd92d8 100644 --- a/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.tsx +++ b/plugins/xcmetrics/src/components/BuildDetails/BuildDetails.tsx @@ -78,7 +78,7 @@ export const BuildDetails = ({ {formatStatus(build.buildStatus)} ), - xcode: `${xcode.version} (${xcode.buildNumber})`, + xcode: xcode ? `${xcode.version} (${xcode.buildNumber})` : 'Unknown', CI: build.isCi, }; diff --git a/yarn.lock b/yarn.lock index c28d72146b..98e69c70a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -69,25 +69,25 @@ dependencies: tslib "~2.0.1" -"@asyncapi/avro-schema-parser@^0.2.1": - version "0.2.1" - resolved "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-0.2.1.tgz#0b7d9953e12084e6f556db261ae08bd8f6690faa" - integrity sha512-RZJaHsdYM4dChYSrb/TWrVCn/r2qcus+9/8iLL8+SMINHb0ECgH8tFZFJpr3Tq+LV2SBFaRQ+9kuecjQ8BNDSA== +"@asyncapi/avro-schema-parser@^0.3.0": + version "0.3.0" + resolved "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-0.3.0.tgz#6922acc559ef999c57e81297d78ffe680fc92b3c" + integrity sha512-gWAqS2CKxbChdX8hZY+5EYQl6atP8FTSBvoG5mGGQ89XUoNdlLX14lsvbgvBnDj5sSwqfs+b5Mh5PUZMR/8maA== -"@asyncapi/openapi-schema-parser@^2.0.1": +"@asyncapi/openapi-schema-parser@^2.0.0": version "2.0.1" resolved "https://registry.npmjs.org/@asyncapi/openapi-schema-parser/-/openapi-schema-parser-2.0.1.tgz#4d6e82cced907b14e0ad6f98261ff2562d968d96" integrity sha512-algbtdM1gcAOa8+V8kp7WeBhdaNac82jmZUXx8YjyNfRVo02N2juDrjeBAGJd+FNva9Mb4MM7qfkJoAFpTL5VQ== dependencies: "@openapi-contrib/openapi-schema-to-json-schema" "^3.0.0" -"@asyncapi/parser@^1.5.0": - version "1.5.0" - resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.5.0.tgz#d70616a1e6081c7dd86957befd5dccc46b9a77df" - integrity sha512-HzrehCcT6R+iqtktNmrUM9wRUEMBqnCeXIrGJT0txBMS1QduNRmXGjvuDpxqwyaATPH/xu1gSp2l6pFP/hyVbA== +"@asyncapi/parser@^1.13.0": + version "1.13.0" + resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.13.0.tgz#bc20d53ba870ead9d26d5a4e3a838c5765ba8d4c" + integrity sha512-UnTYSjx2Sc+VKWIIeIV5I+ogFbN5iWzNgx1UgTXQ5oPhtNlsdIkF+w7qV8GyiraaKbBqAuwepv4xbBvlEi8hkw== dependencies: "@apidevtools/json-schema-ref-parser" "^9.0.6" - "@asyncapi/specs" "^2.7.7" + "@asyncapi/specs" "^2.11.0" "@fmvilas/pseudo-yaml-ast" "^0.3.1" ajv "^6.10.1" js-yaml "^3.13.1" @@ -96,25 +96,24 @@ node-fetch "^2.6.0" tiny-merge-patch "^0.1.2" -"@asyncapi/react-component@^0.23.0": - version "0.23.1" - resolved "https://registry.npmjs.org/@asyncapi/react-component/-/react-component-0.23.1.tgz#6ae574aac86d835e6845268d49803c4808f37b97" - integrity sha512-xBH8F//sF4Sb7M95eVDnQDG+czklQieFKHkCr8SJmmb2j4NylAOTJBoYSZJ3QTC9r0O+SxdQglwh+u1mhMAU+A== +"@asyncapi/react-component@^1.0.0-next.25": + version "1.0.0-next.25" + resolved "https://registry.npmjs.org/@asyncapi/react-component/-/react-component-1.0.0-next.25.tgz#76b27e0e239b03c32499ee5cd8977e5ceb74f754" + integrity sha512-S+HguQ6IltQWiy+jO7hlmANJYxJ2UOvH7iMAw+YTcDtYGOe/mrDr7vbrrB3HigZRYIyZSV34cNgNOhw/psVHlg== dependencies: - "@asyncapi/avro-schema-parser" "^0.2.1" - "@asyncapi/openapi-schema-parser" "^2.0.1" - "@asyncapi/parser" "^1.5.0" - constate "^1.2.0" - dompurify "^2.1.1" - markdown-it "^11.0.1" - merge "^2.1.0" - openapi-sampler "^1.0.0-beta.15" - react-use "^12.2.0" + "@asyncapi/avro-schema-parser" "^0.3.0" + "@asyncapi/openapi-schema-parser" "^2.0.0" + "@asyncapi/parser" "^1.13.0" + highlight.js "^10.7.2" + isomorphic-dompurify "^0.13.0" + marked "^2.1.1" + openapi-sampler "^1.1.0" + use-resize-observer "^7.0.0" -"@asyncapi/specs@^2.7.7": - version "2.7.7" - resolved "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.7.7.tgz#10f72c95153a3cc10039f6ba9c3a6f7c3b7fecfc" - integrity sha512-z8kj4GDJ640DU4msRsWprvmuC9n7vIeJW+D7Tp1xdefoLX5ZJrK7+4Xruna513wV0fSLpFzCmGz7McEP6CtKDg== +"@asyncapi/specs@^2.11.0": + version "2.12.0" + resolved "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.12.0.tgz#15534ca9e518b561d33edfdfdbb234f9b274f261" + integrity sha512-X4Xkrl+9WXSk5EJhsueIxNx6ymHI5wpkw4ofetV+VRnPLNob/XO4trPSJClrL5hlknxbGADLvlrkI5d3XJ996g== "@azure/abort-controller@^1.0.0": version "1.0.2" @@ -3674,6 +3673,11 @@ resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== +"@juggle/resize-observer@^3.3.1": + version "3.3.1" + resolved "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0" + integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw== + "@kubernetes/client-node@^0.16.0": version "0.16.1" resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.16.1.tgz#c78ef667579777c1a532983922807e228dbc9b90" @@ -6923,9 +6927,9 @@ commander "*" "@types/compression@^1.7.0": - version "1.7.0" - resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.0.tgz#8dc2a56604873cf0dd4e746d9ae4d31ae77b2390" - integrity sha512-3LzWUM+3k3XdWOUk/RO+uSjv7YWOatYq2QADJntK1pjkk4DfVP0KrIEPDnXRJxAAGKe0VpIPRmlINLDuCedZWw== + version "1.7.2" + resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.2.tgz#7cc1cdb01b4730eea284615a68fc70a2cdfd5e71" + integrity sha512-lwEL4M/uAGWngWFLSG87ZDr2kLrbuR8p7X+QZB1OQlT+qkHsCPDVFnHPyXf4Vyl4yDDorNY+mAhosxkCvppatg== dependencies: "@types/express" "*" @@ -7092,6 +7096,13 @@ "@types/docker-modem" "*" "@types/node" "*" +"@types/dompurify@^2.1.0": + version "2.3.1" + resolved "https://registry.npmjs.org/@types/dompurify/-/dompurify-2.3.1.tgz#2934adcd31c4e6b02676f9c22f9756e5091c04dd" + integrity sha512-YJth9qa0V/E6/XPH1Jq4BC8uCMmO8V1fKWn8PCvuZcAhMn7q0ez9LW6naQT04UZzjFfAPhyRMZmI2a2rbMlEFA== + dependencies: + "@types/trusted-types" "*" + "@types/dompurify@^2.2.2": version "2.2.3" resolved "https://registry.npmjs.org/@types/dompurify/-/dompurify-2.2.3.tgz#6e89677a07902ac1b6821c345f34bd85da239b08" @@ -7881,13 +7892,6 @@ dependencies: "@types/react" "*" -"@types/react-wait@^0.3.0": - version "0.3.1" - resolved "https://registry.npmjs.org/@types/react-wait/-/react-wait-0.3.1.tgz#193cbd8fe86baa53b6f65dfa73f03d562f462a27" - integrity sha512-BS9AEjWZItDgpx6LlICcuf53M27zBFCsHx/llCbrmrt/WI7ecG2LGquCss3n8O8bwEDiTX4yYLjy8yLeIfgYTg== - dependencies: - "@types/react" "*" - "@types/react@*", "@types/react@>=16.9.0": version "16.14.18" resolved "https://registry.npmjs.org/@types/react/-/react-16.14.18.tgz#b2bcea05ee244fde92d409f91bd888ca8e54b20f" @@ -8765,6 +8769,11 @@ abab@^2.0.3: resolved "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a" integrity sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg== +abab@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" + integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== + abbrev@1: version "1.1.1" resolved "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" @@ -8828,6 +8837,11 @@ acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.2.4: + version "8.6.0" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.6.0.tgz#e3692ba0eb1a0c83eaa4f37f5fa7368dd7142895" + integrity sha512-U1riIR+lBSNi3IbxtaHOIKdH8sLFv3NYfNv8sg7ZsNhcfl4HF2++BfqqrNAxoCLQW1iiylOj76ecnaUxz+z9yw== + acorn@^8.4.1: version "8.4.1" resolved "https://registry.npmjs.org/acorn/-/acorn-8.4.1.tgz#56c36251fc7cabc7096adc18f05afe814321a28c" @@ -11639,11 +11653,6 @@ constants-browserify@^1.0.0: resolved "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= -constate@^1.2.0: - version "1.3.2" - resolved "https://registry.npmjs.org/constate/-/constate-1.3.2.tgz#fa5f0fc292207f1ec21b46a5eb81f59c8b0a8b84" - integrity sha512-aaILV4vXwGTUZaQZHS5F1xBV8wRCR0Ow1505fdkS5/BPg6hbQrhNqdHL4wgxWgaDeEj43mu/Fb+LhqOKTMcrgQ== - contains-path@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" @@ -11803,7 +11812,7 @@ copy-descriptor@^0.1.0: resolved "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -copy-to-clipboard@^3, copy-to-clipboard@^3.1.0, copy-to-clipboard@^3.2.0, copy-to-clipboard@^3.3.1: +copy-to-clipboard@^3, copy-to-clipboard@^3.2.0, copy-to-clipboard@^3.3.1: version "3.3.1" resolved "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz#115aa1a9998ffab6196f93076ad6da3b913662ae" integrity sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw== @@ -12294,7 +12303,7 @@ cssom@~0.3.6: resolved "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== -cssstyle@^2.2.0: +cssstyle@^2.2.0, cssstyle@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== @@ -12761,6 +12770,11 @@ decimal.js@^10.2.0: resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.0.tgz#39466113a9e036111d02f82489b5fd6b0b5ed231" integrity sha512-vDPw+rDgn3bZe1+F/pyEwb1oMG2XTlRVgAa6B4KccTEpYgF8w6eQllVbQcfIJnZyvzFtFpxnpGtx8dd7DJp/Rw== +decimal.js@^10.2.1: + version "10.3.1" + resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" + integrity sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ== + decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" @@ -13239,7 +13253,7 @@ domhandler@^4.2.0: dependencies: domelementtype "^2.2.0" -dompurify@^2.1.1, dompurify@^2.2.9: +dompurify@^2.2.7, dompurify@^2.2.9: version "2.3.3" resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.3.3.tgz#c1af3eb88be47324432964d8abc75cf4b98d634c" integrity sha512-dqnqRkPMAjOZE0FogZ+ceJNM2dZ3V/yNOuFB7+39qpO93hHhfRpHw3heYQC7DPK9FqbQTfBKUJhiSfz4MvXYwg== @@ -13558,11 +13572,6 @@ entities@^2.0.0, entities@~2.1.0: resolved "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== -entities@~2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4" - integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw== - env-paths@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43" @@ -13890,6 +13899,18 @@ escodegen@^1.14.1: optionalDependencies: source-map "~0.6.1" +escodegen@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd" + integrity sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + eslint-config-prettier@^8.3.0: version "8.3.0" resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz#f7471b20b6fe8a9a9254cc684454202886a2dd7a" @@ -14398,9 +14419,9 @@ expect@^26.6.2: jest-regex-util "^26.0.0" express-prom-bundle@^6.3.6: - version "6.3.6" - resolved "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-6.3.6.tgz#c8da1c1024edfcc54953c365991aca57ffd0cfda" - integrity sha512-IRsTRCEKCVCHEriQlZ1FuutjEFc89KASsveXh+1HcGEnuZKiAC4LugxrsGEIdySqYvqOYSr2SWHJ6L8/BK2SHA== + version "6.4.1" + resolved "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-6.4.1.tgz#a688050b9e090f6969825c33143106d3e0e5a70e" + integrity sha512-Sg0svLQe/SS5z1tHDTVfZVjNumobiDlXM0jmemt5Dm9K6BX8z9yCwEr93zbko6fNMR4zKav77iPfxUWi6gAjNA== dependencies: on-finished "^2.3.0" url-value-parser "^2.0.0" @@ -16340,7 +16361,7 @@ highlight.js@^10.1.0, highlight.js@^10.1.1, highlight.js@^10.4.1, highlight.js@^ resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.2.tgz#89319b861edc66c48854ed1e6da21ea89f847360" integrity sha512-oFLl873u4usRM9K63j4ME9u3etNF0PLiJhSQ8rdfuL51Wn3zkD6drf9ZW0dOzjnZI22YYG24z30JcmfCZjMgYg== -highlight.js@~10.7.0: +highlight.js@^10.7.2, highlight.js@~10.7.0: version "10.7.3" resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== @@ -17511,6 +17532,11 @@ is-potential-custom-element-name@^1.0.0: resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397" integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c= +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + is-promise@4.0.0, is-promise@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" @@ -17769,6 +17795,15 @@ isobject@^4.0.0: resolved "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0" integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA== +isomorphic-dompurify@^0.13.0: + version "0.13.0" + resolved "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-0.13.0.tgz#a4dde357e8531018a85ebb2dd56c4794b6739ba3" + integrity sha512-j2/kt/PGbxvfeEm1uiRLlttZkQdn3hFe1rMr/wm3qFnMXSIw0Nmqu79k+TIoSj+KOwO98Sz9TbuNHU7ejv7IZA== + dependencies: + "@types/dompurify" "^2.1.0" + dompurify "^2.2.7" + jsdom "^16.5.2" + isomorphic-fetch@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz#0267b005049046d2421207215d45d6a262b8b8b4" @@ -18504,6 +18539,39 @@ jsdom@^16.4.0: ws "^7.2.3" xml-name-validator "^3.0.0" +jsdom@^16.5.2: + version "16.7.0" + resolved "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" + integrity sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw== + dependencies: + abab "^2.0.5" + acorn "^8.2.4" + acorn-globals "^6.0.0" + cssom "^0.4.4" + cssstyle "^2.3.0" + data-urls "^2.0.0" + decimal.js "^10.2.1" + domexception "^2.0.1" + escodegen "^2.0.0" + form-data "^3.0.0" + html-encoding-sniffer "^2.0.1" + http-proxy-agent "^4.0.1" + https-proxy-agent "^5.0.0" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.0" + parse5 "6.0.1" + saxes "^5.0.1" + symbol-tree "^3.2.4" + tough-cookie "^4.0.0" + w3c-hr-time "^1.0.2" + w3c-xmlserializer "^2.0.0" + webidl-conversions "^6.1.0" + whatwg-encoding "^1.0.5" + whatwg-mimetype "^2.3.0" + whatwg-url "^8.5.0" + ws "^7.4.6" + xml-name-validator "^3.0.0" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -18541,7 +18609,7 @@ json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== -json-pointer@^0.6.0: +json-pointer@^0.6.1: version "0.6.1" resolved "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.1.tgz#3c6caa6ac139e2599f5a1659d39852154015054d" integrity sha512-3OvjqKdCBvH41DLpV4iSt6v2XhZXV1bPB4OROuknvUXI7ZQNofieCPkmE26stEJ9zdQuvIxDHCuYhfgxFAAs+Q== @@ -19586,7 +19654,7 @@ lodash@4.17.15: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -lodash@4.17.21, lodash@^4.17.10, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.0, lodash@~4.17.15, lodash@~4.17.4: +lodash@4.17.21, lodash@^4.17.10, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0, lodash@~4.17.0, lodash@~4.17.15, lodash@~4.17.4: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -19878,17 +19946,6 @@ markdown-escapes@^1.0.0: resolved "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg== -markdown-it@^11.0.1: - version "11.0.1" - resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.1.tgz#b54f15ec2a2193efa66dda1eb4173baea08993d6" - integrity sha512-aU1TzmBKcWNNYvH9pjq6u92BML+Hz3h5S/QpfTFwiQF852pLT+9qHsrhM9JYipkOXZxGn+sGH8oyJE9FD9WezQ== - dependencies: - argparse "^1.0.7" - entities "~2.0.0" - linkify-it "^3.0.1" - mdurl "^1.0.1" - uc.micro "^1.0.5" - markdown-it@^12.2.0: version "12.2.0" resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-12.2.0.tgz#091f720fd5db206f80de7a8d1f1a7035fd0d38db" @@ -19925,6 +19982,11 @@ markdown-to-jsx@^7.1.3: resolved "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.3.tgz#f00bae66c0abe7dd2d274123f84cb6bd2a2c7c6a" integrity sha512-jtQ6VyT7rMT5tPV0g2EJakEnXLiPksnvlYtwQsVVZ611JsWGN8bQ1tVSDX4s6JllfEH6wmsYxNjTUAMrPmNA8w== +marked@^2.1.1: + version "2.1.3" + resolved "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz#bd017cef6431724fd4b27e0657f5ceb14bff3753" + integrity sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA== + math-expression-evaluator@^1.2.14: version "1.2.22" resolved "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.22.tgz#c14dcb3d8b4d150e5dcea9c68c8dad80309b0d5e" @@ -20324,11 +20386,6 @@ merge2@^1.2.3, merge2@^1.3.0: resolved "https://registry.npmjs.org/merge2/-/merge2-1.3.0.tgz#5b366ee83b2f1582c48f87e47cf1a9352103ca81" integrity sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw== -merge@^2.1.0: - version "2.1.1" - resolved "https://registry.npmjs.org/merge/-/merge-2.1.1.tgz#59ef4bf7e0b3e879186436e8481c06a6c162ca98" - integrity sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w== - meros@1.1.4, meros@^1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/meros/-/meros-1.1.4.tgz#c17994d3133db8b23807f62bec7f0cb276cfd948" @@ -21147,7 +21204,7 @@ nan@^2.14.1, nan@^2.15.0: resolved "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ== -nano-css@^5.1.0, nano-css@^5.3.1: +nano-css@^5.3.1: version "5.3.1" resolved "https://registry.npmjs.org/nano-css/-/nano-css-5.3.1.tgz#b709383e07ad3be61f64edffacb9d98250b87a1f" integrity sha512-ENPIyNzANQRyYVvb62ajDd7PAyIgS2LIUnT9ewih4yrXSZX4hKoUwssy8WjUH++kEOA5wUTMgNnV7ko5n34kUA== @@ -21943,12 +22000,13 @@ open@^8.0.9: is-docker "^2.1.1" is-wsl "^2.2.0" -openapi-sampler@^1.0.0-beta.15: - version "1.0.0-beta.16" - resolved "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.0.0-beta.16.tgz#7813524d5b88d222efb772ceb5a809075d6d9174" - integrity sha512-05+GvwMagTY7GxoDQoWJfmAUFlxfebciiEzqKmu4iq6+MqBEn62AMUkn0CTxyKhnUGIaR2KXjTeslxIeJwVIOw== +openapi-sampler@^1.1.0: + version "1.1.1" + resolved "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.1.1.tgz#7bba7000a03cd8a4630bfbe5b3ef258990c78400" + integrity sha512-WAFsl5SPYuhQwaMTDFOcKhnEY1G1rmamrMiPmJdqwfl1lr81g63/befcsN9BNi0w5/R0L+hfcUj13PANEBeLgg== dependencies: - json-pointer "^0.6.0" + "@types/json-schema" "^7.0.7" + json-pointer "^0.6.1" openid-client@^4.1.1, openid-client@^4.2.1: version "4.9.0" @@ -22445,7 +22503,7 @@ parse5@5.1.1: resolved "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== -parse5@^6.0.0: +parse5@6.0.1, parse5@^6.0.0: version "6.0.1" resolved "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== @@ -24151,11 +24209,6 @@ react-error-overlay@^6.0.9: resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== -react-fast-compare@^2.0.4: - version "2.0.4" - resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" - integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== - react-fast-compare@^3.0.1, react-fast-compare@^3.1.1, react-fast-compare@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" @@ -24473,23 +24526,6 @@ react-universal-interface@^0.6.2: resolved "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz#5e8d438a01729a4dbbcbeeceb0b86be146fe2b3b" integrity sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw== -react-use@^12.2.0: - version "12.13.0" - resolved "https://registry.npmjs.org/react-use/-/react-use-12.13.0.tgz#dfefd8145552841f1c2213c2e79966b505a264ba" - integrity sha512-Kh0m9ezIn9xfRycx4jdAgvsYGstGfjTBO2ecIM3+G0RqrpMxTIL5jjYraHYfUzjGBHf7dUhNYBzm5vw5LItVZA== - dependencies: - "@types/react-wait" "^0.3.0" - copy-to-clipboard "^3.1.0" - nano-css "^5.1.0" - react-fast-compare "^2.0.4" - react-wait "^0.3.0" - resize-observer-polyfill "^1.5.1" - screenfull "^5.0.0" - set-harmonic-interval "^1.0.1" - throttle-debounce "^2.0.1" - ts-easing "^0.2.0" - tslib "^1.10.0" - react-use@^17.2.4: version "17.2.4" resolved "https://registry.npmjs.org/react-use/-/react-use-17.2.4.tgz#1f89be3db0a8237c79253db0a15e12bbe3cfeff1" @@ -24522,11 +24558,6 @@ react-virtualized@^9.21.0: prop-types "^15.6.0" react-lifecycles-compat "^3.0.4" -react-wait@^0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/react-wait/-/react-wait-0.3.0.tgz#0cdd4d919012451a5bc3ab0a16d00c6fd9a8c10b" - integrity sha512-kB5x/kMKWcn0uVr9gBdNz21/oGbQwEQnF3P9p6E9yLfJ9DRcKS0fagbgYMFI0YFOoyKDj+2q6Rwax0kTYJF37g== - react@^16.0.0, react@^16.12.0, react@^16.13.1: version "16.13.1" resolved "https://registry.npmjs.org/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" @@ -25632,7 +25663,7 @@ sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== -saxes@^5.0.0: +saxes@^5.0.0, saxes@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== @@ -25688,7 +25719,7 @@ scoped-regex@^2.0.0: resolved "https://registry.npmjs.org/scoped-regex/-/scoped-regex-2.1.0.tgz#7b9be845d81fd9d21d1ec97c61a0b7cf86d2015f" integrity sha512-g3WxHrqSWCZHGHlSrF51VXFdjImhwvH8ZO/pryFH56Qi0cDsZfylQa/t0jCzVQFNbNvM00HfHjkDPEuarKDSWQ== -screenfull@^5.0.0, screenfull@^5.1.0: +screenfull@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/screenfull/-/screenfull-5.1.0.tgz#85c13c70f4ead4c1b8a935c70010dfdcd2c0e5c8" integrity sha512-dYaNuOdzr+kc6J6CFcBrzkLCfyGcMg+gWkJ8us93IQ7y1cevhQAugFsaCdMHb6lw8KV3xPzSxzH7zM1dQap9mA== @@ -27530,11 +27561,6 @@ throat@^5.0.0: resolved "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== -throttle-debounce@^2.0.1: - version "2.2.1" - resolved "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.2.1.tgz#fbd933ae6793448816f7d5b3cae259d464c98137" - integrity sha512-i9hAVld1f+woAiyNGqWelpDD5W1tpMroL3NofTz9xzwq6acWBlO2dC8k5EFSZepU6oOINtV5Q3aSPoRg7o4+fA== - throttle-debounce@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz#32f94d84dfa894f786c9a1f290e7a645b6a19abb" @@ -27758,6 +27784,13 @@ tr46@^2.0.2: dependencies: punycode "^2.1.1" +tr46@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" + integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== + dependencies: + punycode "^2.1.1" + "traverse@>=0.3.0 <0.4": version "0.3.9" resolved "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" @@ -28639,6 +28672,13 @@ use-memo-one@^1.1.1: resolved "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.1.tgz#39e6f08fe27e422a7d7b234b5f9056af313bd22c" integrity sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ== +use-resize-observer@^7.0.0: + version "7.1.0" + resolved "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-7.1.0.tgz#709ea7540fbe0a60ceae41ee2bef933d7782e4d4" + integrity sha512-6DGWOnZpjAGP/MtslGg7OunZptyueQduMi0i8DC5nVKXtJ8Bdt0wR/1tSxugFRndzYCi/jtD+SlNs5PK8ijvXQ== + dependencies: + "@juggle/resize-observer" "^3.3.1" + use@^3.1.0: version "3.1.1" resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" @@ -29233,6 +29273,15 @@ whatwg-url@^8.0.0, whatwg-url@^8.4.0: tr46 "^2.0.2" webidl-conversions "^6.1.0" +whatwg-url@^8.5.0: + version "8.7.0" + resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" + integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg== + dependencies: + lodash "^4.7.0" + tr46 "^2.1.0" + webidl-conversions "^6.1.0" + which-boxed-primitive@^1.0.1, which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"