badges-backend: add router and api tests.
Signed-off-by: Andreas Stenius <andreas.stenius@svenskaspel.se>
This commit is contained in:
committed by
Fredrik Adelöw
parent
b55ba86c74
commit
19f4048e75
@@ -1,13 +1,46 @@
|
||||
# Badges Backend
|
||||
|
||||
Backend plugin for serving badges. Default implementation uses
|
||||
[badge-maker](https://www.npmjs.com/package/badge-maker) for creating
|
||||
the badges, in SVG.
|
||||
[badge-maker](https://www.npmjs.com/package/badge-maker) for creating the
|
||||
badges, in SVG.
|
||||
|
||||
Currently, only entity badges are implemented. i.e. badges that may have entity
|
||||
specific information in them, and as such, are served from a entity specific
|
||||
endpoint.
|
||||
|
||||
## Setup
|
||||
|
||||
The list of all badges to offer are passed to the badges-backend
|
||||
`createRouter()`.
|
||||
The list of all badges to offer are passed as an object with badge factories to
|
||||
the badges-backend `createRouter()` during plugin registration.
|
||||
|
||||
## Badge builder
|
||||
|
||||
Badges are created by classes implementing the `BadgeBuilder` type. The default
|
||||
badge builder uses badge factories to turn a `BadgeContext` into a `Badge` spec
|
||||
for the `badge-maker` to create the SVG image.
|
||||
|
||||
### Default badges
|
||||
|
||||
A set of default badge factories are defined in
|
||||
[badges.ts](https://github.com/backstage/backstage/tree/master/plugins/badges-backend/src/badges.ts)
|
||||
as examples.
|
||||
|
||||
Additional badges may be provided in your application by defining custom badge
|
||||
factories, and provide them to the badge builder.
|
||||
|
||||
## API
|
||||
|
||||
The badges backend api exposes two main endpoints for entity badges. (the
|
||||
`/badges` prefix is arbitrary, and the default for the example backend.)
|
||||
|
||||
- `/badges/entity/:namespace/:kind/:name/badge-specs` List all defined badges
|
||||
for a particular entity, in json format. See
|
||||
[BadgeSpec](https://github.com/backstage/backstage/tree/master/plugins/badges/src/api/types.ts)
|
||||
from the frontend plugin for a type declaration.
|
||||
|
||||
- `/badges/entity/:namespace/:kind/:name/:badgeId` Get the entity badge as an
|
||||
SVG image. If the `accept` request header prefers `application/json` the badge
|
||||
spec as JSON will be returned instead of the image.
|
||||
|
||||
## Links
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.5.5",
|
||||
"@backstage/catalog-client": "^0.3.6",
|
||||
"@backstage/catalog-model": "^0.7.3",
|
||||
"@backstage/config": "^0.1.3",
|
||||
"@types/express": "^4.17.6",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './badges';
|
||||
export * from './lib';
|
||||
export * from './service/router';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
export * from './badges';
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2021 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 { Config } from '@backstage/config';
|
||||
import { DefaultBadgeBuilder } from './DefaultBadgeBuilder';
|
||||
import { BadgeBuilder, BadgeOptions } from './types';
|
||||
import { BadgeContext, BadgeFactories } from '../../types';
|
||||
|
||||
describe('DefaultBadgeBuilder', () => {
|
||||
let builder: BadgeBuilder;
|
||||
let config: jest.Mocked<Config>;
|
||||
let factories: BadgeFactories;
|
||||
|
||||
const badge = {
|
||||
description: 'a test badge',
|
||||
label: 'test',
|
||||
message: 'ok',
|
||||
link: 'http://example.com/badgelink',
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
config = {
|
||||
get: jest.fn(),
|
||||
getBoolean: jest.fn(),
|
||||
getConfig: jest.fn(),
|
||||
getConfigArray: jest.fn(),
|
||||
getNumber: jest.fn(),
|
||||
getOptional: jest.fn(),
|
||||
getOptionalBoolean: jest.fn(),
|
||||
getOptionalConfig: jest.fn(),
|
||||
getOptionalConfigArray: jest.fn(),
|
||||
getOptionalNumber: jest.fn(),
|
||||
getOptionalString: jest.fn(),
|
||||
getOptionalStringArray: jest.fn(),
|
||||
getString: jest.fn(),
|
||||
getStringArray: jest.fn(),
|
||||
has: jest.fn(),
|
||||
keys: jest.fn(),
|
||||
};
|
||||
|
||||
factories = {
|
||||
testbadge: {
|
||||
createBadge: () => badge,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
builder = new DefaultBadgeBuilder(factories);
|
||||
});
|
||||
|
||||
it('getBadgeIds() returns all badge factory ids', async () => {
|
||||
expect(await builder.getBadgeIds()).toEqual(['testbadge']);
|
||||
});
|
||||
|
||||
describe('createBadge', () => {
|
||||
const context: BadgeContext = {
|
||||
badgeUrl: 'http://127.0.0.1/badge/url',
|
||||
config,
|
||||
};
|
||||
|
||||
it('returns the spec when format is "json"', async () => {
|
||||
const options: BadgeOptions = {
|
||||
badgeId: 'testbadge',
|
||||
context,
|
||||
format: 'json',
|
||||
};
|
||||
|
||||
const spec = await builder.createBadge(options);
|
||||
expect(JSON.parse(spec)).toEqual({
|
||||
badge,
|
||||
id: 'testbadge',
|
||||
url: context.badgeUrl,
|
||||
markdown: `[](${badge.link})`,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the badge image when format is "svg"', async () => {
|
||||
const options: BadgeOptions = {
|
||||
badgeId: 'testbadge',
|
||||
context,
|
||||
format: 'svg',
|
||||
};
|
||||
|
||||
const spec = await builder.createBadge(options);
|
||||
expect(spec).toEqual(expect.stringMatching(/^<svg[^>]*>.*<\/svg>$/));
|
||||
});
|
||||
|
||||
it('returns "unknown" badge for missing factory', async () => {
|
||||
const options: BadgeOptions = {
|
||||
badgeId: 'other-id',
|
||||
context,
|
||||
format: 'json',
|
||||
};
|
||||
|
||||
const spec = await builder.createBadge(options);
|
||||
expect(JSON.parse(spec)).toEqual({
|
||||
badge: {
|
||||
label: 'unknown badge',
|
||||
message: 'other-id',
|
||||
color: 'red',
|
||||
},
|
||||
id: 'other-id',
|
||||
url: context.badgeUrl,
|
||||
markdown: ``,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,12 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* adapted from https://stackoverflow.com/a/41015840/444060
|
||||
*/
|
||||
export function interpolate(template: string, params: object): string {
|
||||
const names = Object.keys(params);
|
||||
const vals = Object.values(params);
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function(...names, `return \`${template}\`;`)(...vals);
|
||||
}
|
||||
export * from './BadgeBuilder';
|
||||
@@ -14,19 +14,152 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as winston from 'winston';
|
||||
import {
|
||||
loadBackendConfig,
|
||||
SingleHostDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { createRouter } from './router';
|
||||
import { BadgeBuilder } from '../lib';
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
let badgeBuilder: jest.Mocked<BadgeBuilder>;
|
||||
let catalog: jest.Mocked<CatalogApi>;
|
||||
let config: jest.Mocked<Config>;
|
||||
|
||||
const entity: Entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'service',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
const badge = {
|
||||
id: 'test-badge',
|
||||
badge: { label: 'test badge' },
|
||||
url: '/...',
|
||||
markdown: '[]',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
badgeBuilder = {
|
||||
createBadge: jest.fn(),
|
||||
getBadgeIds: jest.fn(),
|
||||
};
|
||||
catalog = {
|
||||
addLocation: jest.fn(),
|
||||
getEntities: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
};
|
||||
config = {
|
||||
get: jest.fn(),
|
||||
getBoolean: jest.fn(),
|
||||
getConfig: jest.fn(),
|
||||
getConfigArray: jest.fn(),
|
||||
getNumber: jest.fn(),
|
||||
getOptional: jest.fn(),
|
||||
getOptionalBoolean: jest.fn(),
|
||||
getOptionalConfig: jest.fn(),
|
||||
getOptionalConfigArray: jest.fn(),
|
||||
getOptionalNumber: jest.fn(),
|
||||
getOptionalString: jest.fn(),
|
||||
getOptionalStringArray: jest.fn(),
|
||||
getString: jest.fn(),
|
||||
getStringArray: jest.fn(),
|
||||
has: jest.fn(),
|
||||
keys: jest.fn(),
|
||||
};
|
||||
const router = await createRouter({ badgeBuilder, catalog, config });
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('works', async () => {
|
||||
const logger = winston.createLogger();
|
||||
const config = await loadBackendConfig({ logger, argv: [] });
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
const router = await createRouter({ config, discovery });
|
||||
const router = await createRouter({ badgeBuilder, catalog, config });
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
describe('GET /entity/:namespace/:kind/:name/badge-specs', () => {
|
||||
it('returns all badge specs for entity', async () => {
|
||||
catalog.getEntityByName.mockResolvedValueOnce(entity);
|
||||
|
||||
badgeBuilder.getBadgeIds.mockResolvedValueOnce([badge.id]);
|
||||
badgeBuilder.createBadge.mockResolvedValueOnce(
|
||||
JSON.stringify(badge, null, 2),
|
||||
);
|
||||
|
||||
const response = await request(app).get(
|
||||
'/entity/default/service/test/badge-specs',
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.text).toEqual(`[${JSON.stringify(badge, null, 2)}]`);
|
||||
|
||||
expect(catalog.getEntityByName).toHaveBeenCalledTimes(1);
|
||||
expect(catalog.getEntityByName).toHaveBeenCalledWith({
|
||||
namespace: 'default',
|
||||
kind: 'service',
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
expect(badgeBuilder.getBadgeIds).toHaveBeenCalledTimes(1);
|
||||
expect(badgeBuilder.createBadge).toHaveBeenCalledTimes(1);
|
||||
expect(badgeBuilder.createBadge).toHaveBeenCalledWith({
|
||||
badgeId: badge.id,
|
||||
context: {
|
||||
badgeUrl: expect.stringMatching(
|
||||
/http:\/\/127.0.0.1:\d+\/entity\/default\/service\/test\/test-badge/,
|
||||
),
|
||||
config,
|
||||
entity,
|
||||
},
|
||||
format: 'json',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /entity/:namespace/:kind/:name/test-badge', () => {
|
||||
it('returns badge for entity', async () => {
|
||||
catalog.getEntityByName.mockResolvedValueOnce(entity);
|
||||
|
||||
const image = '<svg>...</svg>';
|
||||
badgeBuilder.createBadge.mockResolvedValueOnce(image);
|
||||
|
||||
const response = await request(app).get(
|
||||
'/entity/default/service/test/test-badge',
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(Buffer.from(image));
|
||||
|
||||
expect(catalog.getEntityByName).toHaveBeenCalledTimes(1);
|
||||
expect(catalog.getEntityByName).toHaveBeenCalledWith({
|
||||
namespace: 'default',
|
||||
kind: 'service',
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
expect(badgeBuilder.getBadgeIds).toHaveBeenCalledTimes(0);
|
||||
expect(badgeBuilder.createBadge).toHaveBeenCalledTimes(1);
|
||||
expect(badgeBuilder.createBadge).toHaveBeenCalledWith({
|
||||
badgeId: badge.id,
|
||||
context: {
|
||||
badgeUrl: expect.stringMatching(
|
||||
/http:\/\/127.0.0.1:\d+\/entity\/default\/service\/test\/test-badge/,
|
||||
),
|
||||
config,
|
||||
entity,
|
||||
},
|
||||
format: 'svg',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,35 +15,40 @@
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import fetch from 'cross-fetch';
|
||||
import Router from 'express-promise-router';
|
||||
import {
|
||||
errorHandler,
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Config, JsonObject } from '@backstage/config';
|
||||
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
|
||||
import { Config } from '@backstage/config';
|
||||
import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder';
|
||||
import { BadgeContext, BadgeFactories } from '../types';
|
||||
|
||||
export interface RouterOptions {
|
||||
badgeBuilder?: BadgeBuilder;
|
||||
badgeFactories?: BadgeFactories;
|
||||
catalog?: CatalogApi;
|
||||
config: Config;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
discovery?: PluginEndpointDiscovery;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const router = Router();
|
||||
if (!options.catalog && !options.discovery) {
|
||||
throw new Error('must provide either catalog api or discovery api');
|
||||
}
|
||||
const catalog =
|
||||
options.catalog || new CatalogClient({ discoveryApi: options.discovery! });
|
||||
const badgeBuilder =
|
||||
options.badgeBuilder ||
|
||||
new DefaultBadgeBuilder(options.badgeFactories || {});
|
||||
const router = Router();
|
||||
|
||||
router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => {
|
||||
const entityUri = getEntityUri(req.params);
|
||||
const entity = await getEntity(options.discovery, entityUri);
|
||||
const { namespace, kind, name } = req.params;
|
||||
const entity = await catalog.getEntityByName({ namespace, kind, name });
|
||||
if (!entity) {
|
||||
res.status(404).send(`Unknown entity`);
|
||||
return;
|
||||
@@ -78,10 +83,8 @@ export async function createRouter(
|
||||
});
|
||||
|
||||
router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => {
|
||||
const { badgeId } = req.params;
|
||||
|
||||
const entityUri = getEntityUri(req.params);
|
||||
const entity = await getEntity(options.discovery, entityUri);
|
||||
const { namespace, kind, name, badgeId } = req.params;
|
||||
const entity = await catalog.getEntityByName({ namespace, kind, name });
|
||||
if (!entity) {
|
||||
res.status(404).send(`Unknown entity`);
|
||||
return;
|
||||
@@ -117,21 +120,3 @@ export async function createRouter(
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function getEntityUri(params: JsonObject): string {
|
||||
const { kind, namespace, name } = params;
|
||||
return `${kind}/${namespace}/${name}`;
|
||||
}
|
||||
|
||||
async function getEntity(
|
||||
discovery: PluginEndpointDiscovery,
|
||||
entityUri: string,
|
||||
): Promise<Entity> {
|
||||
const catalogUrl = await discovery.getBaseUrl('catalog');
|
||||
|
||||
const entity = (await (
|
||||
await fetch(`${catalogUrl}/entities/by-name/${entityUri}`)
|
||||
).json()) as Entity;
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user