badges-backend: add router and api tests.

Signed-off-by: Andreas Stenius <andreas.stenius@svenskaspel.se>
This commit is contained in:
Andreas Stenius
2021-03-11 13:53:00 +01:00
committed by Fredrik Adelöw
parent b55ba86c74
commit 19f4048e75
8 changed files with 326 additions and 56 deletions
+37 -4
View File
@@ -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
+1
View File
@@ -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",
+2 -2
View File
@@ -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: `[![a test badge, test: ok](${context.badgeUrl} "a test badge")](${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: `![unknown badge: other-id](${context.badgeUrl})`,
});
});
});
});
@@ -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',
});
});
});
});
+14 -29
View File
@@ -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;
}