Add CachedEntityLoader for techdocs

Signed-off-by: Joe Porpeglia <josephp@spotify.com>
This commit is contained in:
Joe Porpeglia
2022-01-05 18:16:10 -05:00
committed by Joe Porpeglia
parent b76fec242d
commit 7dcc6b1fe6
4 changed files with 335 additions and 0 deletions
+1
View File
@@ -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",
@@ -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<CatalogClient> = {
getEntityByName: jest.fn(),
} as any;
const cache: jest.Mocked<CacheClient> = {
get: jest.fn(),
set: jest.fn(),
} as any;
const identity: jest.Mocked<IdentityClient> = {
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,
});
});
});
@@ -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<Entity | undefined> {
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<string> {
const entityRef = stringifyEntityRef(entityName);
if (!token) {
return entityRef;
}
const response = await this.identity.authenticate(token);
return `${entityRef}:${response.identity.userEntityRef}`;
}
}
+116
View File
@@ -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"