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}`;
}
}