Add token to CatalogClient and add wrapper class for frontend

This commit is contained in:
Erik Larsson
2021-01-19 18:38:33 +01:00
parent b52baa518b
commit 3647d0577f
7 changed files with 200 additions and 71 deletions
@@ -18,29 +18,16 @@ import { Entity } from '@backstage/catalog-model';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
import { CatalogListResponse, DiscoveryApi, IdentityApi } from './types';
import { CatalogListResponse, DiscoveryApi } from './types';
const server = setupServer();
const token = 'fake-token';
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discoveryApi: DiscoveryApi = {
async getBaseUrl(_pluginId) {
return mockBaseUrl;
},
};
const identityApi: IdentityApi = {
getUserId() {
return 'jane-fonda';
},
getProfile() {
return { email: 'jane-fonda@spotify.com' };
},
async getIdToken() {
return Promise.resolve('fake-id-token');
},
async signOut() {
return Promise.resolve();
},
};
describe('CatalogClient', () => {
let client: CatalogClient;
@@ -50,7 +37,7 @@ describe('CatalogClient', () => {
afterEach(() => server.resetHandlers());
beforeEach(() => {
client = new CatalogClient({ discoveryApi, identityApi });
client = new CatalogClient({ discoveryApi });
});
describe('getEntities', () => {
@@ -85,7 +72,7 @@ describe('CatalogClient', () => {
});
it('should entities from correct endpoint', async () => {
const response = await client.getEntities();
const response = await client.getEntities(token);
expect(response).toEqual(defaultResponse);
});
@@ -99,7 +86,7 @@ describe('CatalogClient', () => {
}),
);
const response = await client.getEntities({
const response = await client.getEntities(token, {
filter: {
a: '1',
b: ['2', '3'],
@@ -120,7 +107,7 @@ describe('CatalogClient', () => {
}),
);
const response = await client.getEntities({
const response = await client.getEntities(token, {
fields: ['a.b', 'ö'],
});
+48 -36
View File
@@ -24,30 +24,27 @@ import fetch from 'cross-fetch';
import {
AddLocationRequest,
AddLocationResponse,
CatalogApi,
CatalogEntitiesRequest,
CatalogListResponse,
DiscoveryApi,
IdentityApi,
} from './types';
export class CatalogClient implements CatalogApi {
export class CatalogClient {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
constructor(options: { discoveryApi: DiscoveryApi }) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
}
async getLocationById(id: String): Promise<Location | undefined> {
return await this.getOptional(`/locations/${id}`);
async getLocationById(
token: string | undefined,
id: String,
): Promise<Location | undefined> {
return await this.getOptional(token, `/locations/${id}`);
}
async getEntities(
token: string | undefined,
request?: CatalogEntitiesRequest,
): Promise<CatalogListResponse<Entity>> {
const { filter = {}, fields = [] } = request ?? {};
@@ -68,28 +65,35 @@ export class CatalogClient implements CatalogApi {
}
const query = params.length ? `?${params.join('&')}` : '';
const entities: Entity[] = await this.getRequired(`/entities${query}`);
const entities: Entity[] = await this.getRequired(
token,
`/entities${query}`,
);
return { items: entities };
}
async getEntityByName(compoundName: EntityName): Promise<Entity | undefined> {
async getEntityByName(
token: string | undefined,
compoundName: EntityName,
): Promise<Entity | undefined> {
const { kind, namespace = 'default', name } = compoundName;
return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`);
return this.getOptional(
token,
`/entities/by-name/${kind}/${namespace}/${name}`,
);
}
async addLocation({
type = 'url',
target,
dryRun,
}: AddLocationRequest): Promise<AddLocationResponse> {
const idToken = await this.identityApi.getIdToken();
async addLocation(
token: string | undefined,
{ type = 'url', target, dryRun }: AddLocationRequest,
): Promise<AddLocationResponse> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/locations${
dryRun ? '?dryRun=true' : ''
}`,
{
headers: {
authorization: `Bearer ${idToken}`,
authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
method: 'POST',
@@ -118,21 +122,29 @@ export class CatalogClient implements CatalogApi {
};
}
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
async getLocationByEntity(
token: string | undefined,
entity: Entity,
): Promise<Location | undefined> {
const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION];
const all: { data: Location }[] = await this.getRequired('/locations');
const all: { data: Location }[] = await this.getRequired(
token,
'/locations',
);
return all
.map(r => r.data)
.find(l => locationCompound === `${l.type}:${l.target}`);
}
async removeEntityByUid(uid: string): Promise<void> {
const idToken = await this.identityApi.getIdToken();
async removeEntityByUid(
token: string | undefined,
uid: string,
): Promise<void> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/entities/by-uid/${uid}`,
{
headers: {
authorization: `Bearer ${idToken}`,
authorization: `Bearer ${token}`,
},
method: 'DELETE',
},
@@ -150,13 +162,13 @@ export class CatalogClient implements CatalogApi {
// Private methods
//
private async getRequired(path: string): Promise<any> {
private async getRequired(
token: string | undefined,
path: string,
): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const idToken = await this.identityApi.getIdToken();
const response = await fetch(url, {
headers: {
authorization: `Bearer ${idToken}`,
},
headers: token ? { authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
@@ -168,13 +180,13 @@ export class CatalogClient implements CatalogApi {
return await response.json();
}
private async getOptional(path: string): Promise<any | undefined> {
private async getOptional(
token: string | undefined,
path: string,
): Promise<any | undefined> {
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const idToken = await this.identityApi.getIdToken();
const response = await fetch(url, {
headers: {
authorization: `Bearer ${idToken}`,
},
headers: token ? { authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
+7 -1
View File
@@ -15,4 +15,10 @@
*/
export { CatalogClient } from './CatalogClient';
export type { CatalogApi } from './types';
export type {
AddLocationRequest,
AddLocationResponse,
CatalogApi,
CatalogEntitiesRequest,
CatalogListResponse,
} from './types';
-14
View File
@@ -53,17 +53,3 @@ export type AddLocationResponse = {
export type DiscoveryApi = {
getBaseUrl(pluginId: string): Promise<string>;
};
/**
* This is a copy of the core IdentityApi, to avoid importing core.
*/
export type ProfileInfo = {
email?: string;
displayName?: string;
picture?: string;
};
export type IdentityApi = {
getUserId(): string;
getProfile(): ProfileInfo;
getIdToken(): Promise<string | undefined>;
signOut(): Promise<void>;
};
@@ -0,0 +1,65 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { DiscoveryApi, IdentityApi } from '@backstage/core';
import { CatalogClientWrapper } from './CatalogClientWrapper';
jest.mock('./CatalogClient');
const MockedCatalogClient = CatalogClient as jest.Mock<CatalogClient>;
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discoveryApi: DiscoveryApi = {
async getBaseUrl(_pluginId) {
return mockBaseUrl;
},
};
const identityApi: IdentityApi = {
getUserId() {
return 'jane-fonda';
},
getProfile() {
return { email: 'jane-fonda@spotify.com' };
},
async getIdToken() {
return Promise.resolve('fake-id-token');
},
async signOut() {
return Promise.resolve();
},
};
describe('CatalogClientWrapper', () => {
let client: CatalogClientWrapper;
beforeEach(() => {
MockedCatalogClient.mockClear();
client = new CatalogClientWrapper({
client: new MockedCatalogClient({ discoveryApi }),
identityApi,
});
});
describe('getEntities', () => {
it('injects authorization token', async () => {
expect.assertions(2);
await client.getEntities();
const getEntities = MockedCatalogClient.mock.instances[0].getEntities;
expect(getEntities).toHaveBeenCalledWith('fake-id-token', undefined);
expect(getEntities).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,68 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Entity, EntityName, Location } from '@backstage/catalog-model';
import {
AddLocationRequest,
AddLocationResponse,
CatalogApi,
CatalogEntitiesRequest,
CatalogListResponse,
CatalogClient,
} from '@backstage/catalog-client';
import { IdentityApi } from '@backstage/core';
export class CatalogClientWrapper implements CatalogApi {
private readonly identityApi: IdentityApi;
private readonly client: CatalogClient;
constructor(options: { client: CatalogClient; identityApi: IdentityApi }) {
this.client = options.client;
this.identityApi = options.identityApi;
}
async getLocationById(id: String): Promise<Location | undefined> {
const token = await this.identityApi.getIdToken();
return await this.client.getLocationById(token, id);
}
async getEntities(
request?: CatalogEntitiesRequest,
): Promise<CatalogListResponse<Entity>> {
const token = await this.identityApi.getIdToken();
return await this.client.getEntities(token, request);
}
async getEntityByName(compoundName: EntityName): Promise<Entity | undefined> {
const token = await this.identityApi.getIdToken();
return await this.client.getEntityByName(token, compoundName);
}
async addLocation(request: AddLocationRequest): Promise<AddLocationResponse> {
const token = await this.identityApi.getIdToken();
return await this.client.addLocation(token, request);
}
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
const token = await this.identityApi.getIdToken();
return await this.client.getLocationByEntity(token, entity);
}
async removeEntityByUid(uid: string): Promise<void> {
const token = await this.identityApi.getIdToken();
return await this.client.removeEntityByUid(token, uid);
}
}
+6 -1
View File
@@ -27,6 +27,8 @@ import {
catalogRouteRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { catalogRouteRef, entityRouteRef } from './routes';
import { CatalogClientWrapper } from './CatalogClientWrapper';
export const catalogPlugin = createPlugin({
id: 'catalog',
@@ -35,7 +37,10 @@ export const catalogPlugin = createPlugin({
api: catalogApiRef,
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({ discoveryApi, identityApi }) =>
new CatalogClient({ discoveryApi, identityApi }),
new CatalogClientWrapper({
client: new CatalogClient({ discoveryApi }),
identityApi,
}),
}),
],
routes: {