From 7dcc6b1fe663d4ed4ab413420516d78cb92d4dbb Mon Sep 17 00:00:00 2001 From: Joe Porpeglia Date: Wed, 5 Jan 2022 18:16:10 -0500 Subject: [PATCH] Add CachedEntityLoader for techdocs Signed-off-by: Joe Porpeglia --- plugins/techdocs-backend/package.json | 1 + .../src/service/CachedEntityLoader.test.ts | 133 ++++++++++++++++++ .../src/service/CachedEntityLoader.ts | 85 +++++++++++ yarn.lock | 116 +++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 plugins/techdocs-backend/src/service/CachedEntityLoader.test.ts create mode 100644 plugins/techdocs-backend/src/service/CachedEntityLoader.ts diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 6820a31eae..b5433f16da 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -37,6 +37,7 @@ "@backstage/config": "^0.1.13-next.0", "@backstage/errors": "^0.2.0", "@backstage/integration": "^0.7.2-next.0", + "@backstage/plugin-auth-backend": "^0.6.2", "@backstage/search-common": "^0.2.1", "@backstage/techdocs-common": "^0.11.4-next.0", "@types/express": "^4.17.6", diff --git a/plugins/techdocs-backend/src/service/CachedEntityLoader.test.ts b/plugins/techdocs-backend/src/service/CachedEntityLoader.test.ts new file mode 100644 index 0000000000..aa5022d3a6 --- /dev/null +++ b/plugins/techdocs-backend/src/service/CachedEntityLoader.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2022 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 { CachedEntityLoader } from './CachedEntityLoader'; +import { CatalogClient } from '@backstage/catalog-client'; +import { CacheClient } from '@backstage/backend-common'; +import { ResponseError } from '@backstage/errors'; +import { + BackstageIdentityResponse, + IdentityClient, +} from '@backstage/plugin-auth-backend'; +import { EntityName } from '@backstage/catalog-model'; +import { Response } from 'cross-fetch'; + +describe('CachedEntityLoader', () => { + const catalog: jest.Mocked = { + getEntityByName: jest.fn(), + } as any; + + const cache: jest.Mocked = { + get: jest.fn(), + set: jest.fn(), + } as any; + + const identity: jest.Mocked = { + authenticate: jest.fn(), + } as any; + + const entityName: EntityName = { + kind: 'component', + namespace: 'default', + name: 'test', + }; + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + namespace: 'default', + }, + }; + + const token = 'test-token'; + + const identityResponse: BackstageIdentityResponse = { + id: '', + token, + identity: { + type: 'user', + userEntityRef: 'user:default/test-user', + ownershipEntityRefs: [], + }, + }; + + const loader = new CachedEntityLoader({ catalog, cache, identity }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('writes entities to cache', async () => { + identity.authenticate.mockResolvedValue(identityResponse); + cache.get.mockResolvedValue(undefined); + catalog.getEntityByName.mockResolvedValue(entity); + + const result = await loader.load(entityName, token); + + expect(result).toEqual(entity); + expect(cache.set).toBeCalledWith( + 'component:default/test:user:default/test-user', + entity, + { ttl: 5000 }, + ); + }); + + it('returns entities from cache', async () => { + identity.authenticate.mockResolvedValue(identityResponse); + cache.get.mockResolvedValue(entity); + + const result = await loader.load(entityName, token); + + expect(result).toEqual(entity); + expect(catalog.getEntityByName).not.toBeCalled(); + }); + + it('does not cache missing entites', async () => { + identity.authenticate.mockResolvedValue(identityResponse); + cache.get.mockResolvedValue(undefined); + catalog.getEntityByName.mockResolvedValue(undefined); + + const result = await loader.load(entityName, token); + + expect(result).toBeUndefined(); + expect(cache.set).not.toBeCalled(); + }); + + it('transforms 403 responses from catalog to undefined', async () => { + identity.authenticate.mockResolvedValue(identityResponse); + cache.get.mockResolvedValue(undefined); + catalog.getEntityByName.mockRejectedValue( + await ResponseError.fromResponse(new Response(null, { status: 403 })), + ); + + const result = await loader.load(entityName, token); + + expect(result).toBeUndefined(); + }); + + it('uses entity ref as cache key for anonymous users', async () => { + cache.get.mockResolvedValue(undefined); + catalog.getEntityByName.mockResolvedValue(entity); + + const result = await loader.load(entityName, undefined); + + expect(result).toEqual(entity); + expect(cache.set).toBeCalledWith('component:default/test', entity, { + ttl: 5000, + }); + }); +}); diff --git a/plugins/techdocs-backend/src/service/CachedEntityLoader.ts b/plugins/techdocs-backend/src/service/CachedEntityLoader.ts new file mode 100644 index 0000000000..958339d2e9 --- /dev/null +++ b/plugins/techdocs-backend/src/service/CachedEntityLoader.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2022 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 { CatalogClient } from '@backstage/catalog-client'; +import { CacheClient } from '@backstage/backend-common'; +import { + Entity, + EntityName, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { IdentityClient } from '@backstage/plugin-auth-backend'; +import { ResponseError } from '@backstage/errors'; + +export type CachedEntityLoaderOptions = { + catalog: CatalogClient; + cache: CacheClient; + identity: IdentityClient; +}; + +export class CachedEntityLoader { + private catalog: CatalogClient; + private cache: CacheClient; + private identity: IdentityClient; + + constructor({ catalog, cache, identity }: CachedEntityLoaderOptions) { + this.catalog = catalog; + this.cache = cache; + this.identity = identity; + } + + async load( + entityName: EntityName, + token: string | undefined, + ): Promise { + const cacheKey = await this.getCacheKey(entityName, token); + let result = (await this.cache.get(cacheKey)) as Entity | undefined; + + if (result) { + return result; + } + + try { + result = await this.catalog.getEntityByName(entityName, { token }); + } catch (err) { + if (err instanceof ResponseError && err.response.status === 403) { + result = undefined; + } else { + throw err; + } + } + + if (result) { + await this.cache.set(cacheKey, result, { ttl: 5000 }); + } + + return result; + } + + private async getCacheKey( + entityName: EntityName, + token: string | undefined, + ): Promise { + const entityRef = stringifyEntityRef(entityName); + + if (!token) { + return entityRef; + } + + const response = await this.identity.authenticate(token); + + return `${entityRef}:${response.identity.userEntityRef}`; + } +} diff --git a/yarn.lock b/yarn.lock index 99dc841ce7..35b1e26d49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2310,6 +2310,54 @@ "@material-ui/icons" "^4.9.1" react-router-dom "6.0.0-beta.0" +"@backstage/backend-common@^0.10.3": + version "0.10.3" + resolved "https://registry.npmjs.org/@backstage/backend-common/-/backend-common-0.10.3.tgz#32ced91c2585d20d3597a4854004e35c10411a68" + integrity sha512-bfAlBzkskmd9o4MTzkh+gN2uQVIXoQUGLJyPRvunRURg5Ki3UY0IJWb812K3vz/JEnUzB1j3zZbqxpwoTup6ow== + dependencies: + "@backstage/cli-common" "^0.1.6" + "@backstage/config" "^0.1.12" + "@backstage/config-loader" "^0.9.2" + "@backstage/errors" "^0.2.0" + "@backstage/integration" "^0.7.1" + "@backstage/types" "^0.1.1" + "@google-cloud/storage" "^5.8.0" + "@manypkg/get-packages" "^1.1.3" + "@octokit/rest" "^18.5.3" + "@types/cors" "^2.8.6" + "@types/dockerode" "^3.3.0" + "@types/express" "^4.17.6" + archiver "^5.0.2" + aws-sdk "^2.840.0" + compression "^1.7.4" + concat-stream "^2.0.0" + cors "^2.8.5" + dockerode "^3.3.1" + express "^4.17.1" + express-promise-router "^4.1.0" + fs-extra "9.1.0" + git-url-parse "^11.6.0" + helmet "^4.0.0" + isomorphic-git "^1.8.0" + jose "^1.27.1" + keyv "^4.0.3" + keyv-memcache "^1.2.5" + knex "^0.95.1" + lodash "^4.17.21" + logform "^2.3.2" + minimatch "^3.0.4" + minimist "^1.2.5" + morgan "^1.10.0" + node-abort-controller "^3.0.1" + node-fetch "^2.6.1" + raw-body "^2.4.1" + selfsigned "^1.10.7" + stoppable "^1.1.0" + tar "^6.1.2" + unzipper "^0.10.11" + winston "^3.2.1" + yn "^4.0.0" + "@backstage/catalog-client@^0.5.4": version "0.5.4" resolved "https://registry.npmjs.org/@backstage/catalog-client/-/catalog-client-0.5.4.tgz#6162bf460ac0f041d4f0f906172733e7a59df6f9" @@ -2335,6 +2383,27 @@ uuid "^8.0.0" yup "^0.32.9" +"@backstage/config-loader@^0.9.2": + version "0.9.2" + resolved "https://registry.npmjs.org/@backstage/config-loader/-/config-loader-0.9.2.tgz#6e3f0ddcad4a80ead0567442735e2be233979523" + integrity sha512-VNdqKFgeHmzzLkhb2M3TYp4D01aVcBCD1+pCv79sEDoJdcTFy7o1LOmneccn+m6d2poy6in/9zr6VsHgZT0uRA== + dependencies: + "@backstage/cli-common" "^0.1.6" + "@backstage/config" "^0.1.12" + "@backstage/errors" "^0.2.0" + "@backstage/types" "^0.1.1" + "@types/json-schema" "^7.0.6" + ajv "^7.0.3" + chokidar "^3.5.2" + fs-extra "9.1.0" + json-schema "^0.4.0" + json-schema-merge-allof "^0.8.1" + json-schema-traverse "^1.0.0" + node-fetch "^2.6.1" + typescript-json-schema "^0.52.0" + yaml "^1.9.2" + yup "^0.32.9" + "@backstage/config@^0.1.11", "@backstage/config@^0.1.12": version "0.1.12" resolved "https://registry.npmjs.org/@backstage/config/-/config-0.1.12.tgz#7b55dd852c7bddb0b9ef55eb58241f6a1d8a5047" @@ -2481,6 +2550,53 @@ lodash "^4.17.21" luxon "^2.0.2" +"@backstage/plugin-auth-backend@^0.6.2": + version "0.6.2" + resolved "https://registry.npmjs.org/@backstage/plugin-auth-backend/-/plugin-auth-backend-0.6.2.tgz#0180744ac44e7a77e6bbdf237187211dae7060fa" + integrity sha512-Wt8ajzLLiUsgYQuwzekLFeDDytP2uQnwu8N/hFhkMx/9xpEWMTUHCfyg3bFJJ28c5ZMHoiKgyNcC1+2LbLI+2w== + dependencies: + "@backstage/backend-common" "^0.10.3" + "@backstage/catalog-client" "^0.5.4" + "@backstage/catalog-model" "^0.9.9" + "@backstage/config" "^0.1.12" + "@backstage/errors" "^0.2.0" + "@backstage/types" "^0.1.1" + "@google-cloud/firestore" "^4.15.1" + "@types/express" "^4.17.6" + "@types/passport" "^1.0.3" + compression "^1.7.4" + cookie-parser "^1.4.5" + cors "^2.8.5" + express "^4.17.1" + express-promise-router "^4.1.0" + express-session "^1.17.1" + fs-extra "9.1.0" + google-auth-library "^7.6.1" + helmet "^4.0.0" + jose "^1.27.1" + jwt-decode "^3.1.0" + knex "^0.95.1" + lodash "^4.17.21" + luxon "^2.0.2" + minimatch "^3.0.3" + morgan "^1.10.0" + node-cache "^5.1.2" + node-fetch "^2.6.1" + openid-client "^4.2.1" + passport "^0.4.1" + passport-bitbucket-oauth2 "^0.1.2" + passport-github2 "^0.1.12" + passport-gitlab2 "^5.0.0" + passport-google-oauth20 "^2.0.0" + passport-microsoft "^0.1.0" + passport-oauth2 "^1.5.0" + passport-okta-oauth "^0.0.1" + passport-onelogin-oauth "^0.0.1" + passport-saml "^3.1.2" + uuid "^8.0.0" + winston "^3.2.1" + yn "^4.0.0" + "@backstage/plugin-catalog-react@^0.6.11", "@backstage/plugin-catalog-react@^0.6.5": version "0.6.11" resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-0.6.11.tgz#51cc77237c48a645c889103cddb85dabb2159724"