badges: refactoring after review from @freben.

Signed-off-by: Andreas Stenius <andreas.stenius@svenskaspel.se>
This commit is contained in:
Andreas Stenius
2021-03-16 15:12:14 +01:00
committed by Fredrik Adelöw
parent 97836e51f1
commit c46ab4afba
13 changed files with 231 additions and 219 deletions
+5 -5
View File
@@ -6,12 +6,12 @@ Default implementation uses
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
specific information in them, and as such, are served from an entity specific
endpoint.
## Installation
Install the `@backstage/plugin-badges-backend` package in your backend packages,
Install the `@backstage/plugin-badges-backend` package in your backend package,
and then integrate the plugin using the following default setup for
`src/plugins/badges.ts`:
@@ -56,7 +56,7 @@ factories, and provide them to the badge builder.
### Custom badges
To provide custom badges, create a badges factories function, and use that when
To provide custom badges, create a badge factories function, and use that when
creating the badges backend router.
```ts
@@ -80,8 +80,8 @@ export const createMyCustomBadgeFactories = (): BadgeFactories => ({
## API
The badges backend api exposes two main endpoints for entity badges. (the
`/badges` prefix is arbitrary, and the default for the example backend.)
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
+11 -9
View File
@@ -15,6 +15,7 @@
*/
import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { InputError } from '@backstage/errors';
import { Badge, BadgeContext, BadgeFactories } from './types';
function appTitle(context: BadgeContext): string {
@@ -27,14 +28,14 @@ function entityUrl(context: BadgeContext): string {
e.metadata.namespace || ENTITY_DEFAULT_NAMESPACE
}/${e.metadata.name}`;
const catalogUrl = `${context.config.getString('app.baseUrl')}/catalog`;
return `${catalogUrl}/${entityUri}`;
return `${catalogUrl}/${entityUri}`.toLowerCase();
}
export const createDefaultBadgeFactories = (): BadgeFactories => ({
pingback: {
createBadge: (context: BadgeContext): Badge | null => {
createBadge: (context: BadgeContext): Badge => {
if (!context.entity) {
return null;
throw new InputError('"pingback" badge only defined for entities');
}
return {
description: `Link to ${context.entity.metadata.name} in ${appTitle(
@@ -50,9 +51,9 @@ export const createDefaultBadgeFactories = (): BadgeFactories => ({
},
lifecycle: {
createBadge: (context: BadgeContext): Badge | null => {
createBadge: (context: BadgeContext): Badge => {
if (!context.entity) {
return null;
throw new InputError('"lifecycle" badge only defined for entities');
}
return {
description: 'Entity lifecycle badge',
@@ -66,9 +67,9 @@ export const createDefaultBadgeFactories = (): BadgeFactories => ({
},
owner: {
createBadge: (context: BadgeContext): Badge | null => {
createBadge: (context: BadgeContext): Badge => {
if (!context.entity) {
return null;
throw new InputError('"owner" badge only defined for entities');
}
return {
description: 'Entity owner badge',
@@ -82,11 +83,12 @@ export const createDefaultBadgeFactories = (): BadgeFactories => ({
},
docs: {
createBadge: (context: BadgeContext): Badge | null => {
createBadge: (context: BadgeContext): Badge => {
if (!context.entity) {
return null;
throw new InputError('"docs" badge only defined for entities');
}
return {
description: 'Entity docs badge',
kind: 'entity',
label: 'docs',
link: `${entityUrl(context)}/docs`,
@@ -14,14 +14,14 @@
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { Config, ConfigReader } 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 config: Config;
let factories: BadgeFactories;
const badge = {
@@ -32,24 +32,9 @@ describe('DefaultBadgeBuilder', () => {
};
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(),
};
config = new ConfigReader({
backend: { baseUrl: 'http://127.0.0.1' },
});
factories = {
testbadge: {
@@ -63,25 +48,24 @@ describe('DefaultBadgeBuilder', () => {
builder = new DefaultBadgeBuilder(factories);
});
it('getBadgeIds() returns all badge factory ids', async () => {
expect(await builder.getBadgeIds()).toEqual(['testbadge']);
it('getBadges() returns all badge factory ids', async () => {
expect(await builder.getBadges()).toEqual([{ id: 'testbadge' }]);
});
describe('createBadge', () => {
describe('createBadge[Json|Svg]', () => {
const context: BadgeContext = {
badgeUrl: 'http://127.0.0.1/badge/url',
config,
};
it('returns the spec when format is "json"', async () => {
it('badge spec', async () => {
const options: BadgeOptions = {
badgeId: 'testbadge',
badgeInfo: { id: 'testbadge' },
context,
format: 'json',
};
const spec = await builder.createBadge(options);
expect(JSON.parse(spec)).toEqual({
const spec = await builder.createBadgeJson(options);
expect(spec).toEqual({
badge,
id: 'testbadge',
url: context.badgeUrl,
@@ -89,26 +73,24 @@ describe('DefaultBadgeBuilder', () => {
});
});
it('returns the badge image when format is "svg"', async () => {
it('badge image', async () => {
const options: BadgeOptions = {
badgeId: 'testbadge',
badgeInfo: { id: 'testbadge' },
context,
format: 'svg',
};
const spec = await builder.createBadge(options);
expect(spec).toEqual(expect.stringMatching(/^<svg[^>]*>.*<\/svg>$/));
const img = await builder.createBadgeSvg(options);
expect(img).toEqual(expect.stringMatching(/^<svg[^>]*>.*<\/svg>$/));
});
it('returns "unknown" badge for missing factory', async () => {
const options: BadgeOptions = {
badgeId: 'other-id',
badgeInfo: { id: 'other-id' },
context,
format: 'json',
};
const spec = await builder.createBadge(options);
expect(JSON.parse(spec)).toEqual({
const spec = await builder.createBadgeJson(options);
expect(spec).toEqual({
badge: {
label: 'unknown badge',
message: 'other-id',
@@ -14,72 +14,69 @@
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import { makeBadge, Format } from 'badge-maker';
import { BadgeBuilder, BadgeOptions } from './types';
import { BadgeBuilder, BadgeInfo, BadgeOptions, BadgeSpec } from './types';
import { Badge, BadgeFactories } from '../../types';
export class DefaultBadgeBuilder implements BadgeBuilder {
constructor(private readonly factories: BadgeFactories) {}
public async getBadgeIds(): Promise<string[]> {
return Object.keys(this.factories);
public async getBadges(): Promise<BadgeInfo[]> {
return Object.keys(this.factories).map(id => ({ id }));
}
public async createBadge(options: BadgeOptions): Promise<string> {
const factory = this.factories[options.badgeId];
public async createBadgeJson(options: BadgeOptions): Promise<BadgeSpec> {
const factory = this.factories[options.badgeInfo.id];
const badge = factory
? factory.createBadge(options.context)
: ({
label: 'unknown badge',
message: options.badgeId,
message: options.badgeInfo.id,
color: 'red',
} as Badge);
if (!badge) {
return '';
throw new InputError(
`The badge factory failed to produce a "${options.badgeInfo.id}" badge with the provided context`,
);
}
switch (options.format) {
case 'json':
return JSON.stringify(
{
badge,
id: options.badgeId,
url: options.context.badgeUrl,
markdown: this.getMarkdownCode(badge, options.context.badgeUrl),
},
null,
2,
);
case 'svg':
try {
const format = {
message: badge.message,
color: badge.color || '#36BAA2',
label: badge.label || '',
labelColor: badge.labelColor || '',
style: badge.style || 'flat-square',
} as Format;
return makeBadge(format);
} catch (err) {
return makeBadge({
label: 'invalid badge',
message: `${err}`,
color: 'red',
});
}
default:
throw new TypeError(`unsupported badge format: ${options.format}`);
return {
badge,
id: options.badgeInfo.id,
url: options.context.badgeUrl,
markdown: this.getMarkdownCode(badge, options.context.badgeUrl),
};
}
public async createBadgeSvg(options: BadgeOptions): Promise<string> {
const { badge } = await this.createBadgeJson(options);
try {
const format = {
message: badge.message,
color: badge.color || '#36BAA2',
label: badge.label || '',
labelColor: badge.labelColor || '',
style: badge.style || 'flat-square',
} as Format;
return makeBadge(format);
} catch (err) {
return makeBadge({
label: 'invalid badge',
message: `${err}`,
color: 'red',
});
}
}
private getMarkdownCode(params: Badge, badge_url: string): string {
let alt_text = `${params.label}: ${params.message}`;
private getMarkdownCode(params: Badge, badgeUrl: string): string {
let altText = `${params.label}: ${params.message}`;
if (params.description && params.description !== params.label) {
alt_text = `${params.description}, ${alt_text}`;
altText = `${params.description}, ${altText}`;
}
const tooltip = params.description ? ` "${params.description}"` : '';
const img = `![${alt_text}](${badge_url}${tooltip})`;
const img = `![${altText}](${badgeUrl}${tooltip})`;
return params.link ? `[${img}](${params.link})` : img;
}
}
@@ -15,4 +15,4 @@
*/
export { DefaultBadgeBuilder } from './DefaultBadgeBuilder';
export * from './types';
export type { BadgeBuilder, BadgeInfo, BadgeOptions, BadgeSpec } from './types';
@@ -14,15 +14,33 @@
* limitations under the License.
*/
import { BadgeContext } from '../../types';
import { Badge, BadgeContext } from '../../types';
export type BadgeInfo = {
id: string;
};
export type BadgeOptions = {
badgeId: string;
badgeInfo: BadgeInfo;
context: BadgeContext;
format: 'svg' | 'json';
};
export type BadgeSpec = {
/** Badge id */
id: string;
/** Badge data */
badge: Badge;
/** The URL to the badge image */
url: string;
/** The markdown code to use the badge */
markdown: string;
};
export type BadgeBuilder = {
createBadge(options: BadgeOptions): Promise<string>;
getBadgeIds(): Promise<string[]>;
getBadges(): Promise<BadgeInfo[]>;
createBadgeJson(options: BadgeOptions): Promise<BadgeSpec>;
createBadgeSvg(options: BadgeOptions): Promise<string>;
};
@@ -16,9 +16,13 @@
import express from 'express';
import request from 'supertest';
import {
PluginEndpointDiscovery,
SingleHostDiscovery,
} from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import type { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { Config, ConfigReader } from '@backstage/config';
import { createRouter } from './router';
import { BadgeBuilder } from '../lib';
@@ -26,7 +30,8 @@ describe('createRouter', () => {
let app: express.Express;
let badgeBuilder: jest.Mocked<BadgeBuilder>;
let catalog: jest.Mocked<CatalogApi>;
let config: jest.Mocked<Config>;
let config: Config;
let discovery: PluginEndpointDiscovery;
const entity: Entity = {
apiVersion: 'v1',
@@ -38,15 +43,19 @@ describe('createRouter', () => {
const badge = {
id: 'test-badge',
badge: { label: 'test badge' },
badge: {
label: 'test',
message: 'badge',
},
url: '/...',
markdown: '[![...](...)]',
};
beforeAll(async () => {
badgeBuilder = {
createBadge: jest.fn(),
getBadgeIds: jest.fn(),
getBadges: jest.fn(),
createBadgeJson: jest.fn(),
createBadgeSvg: jest.fn(),
};
catalog = {
addLocation: jest.fn(),
@@ -56,25 +65,21 @@ describe('createRouter', () => {
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 });
config = new ConfigReader({
backend: {
baseUrl: 'http://127.0.0.1',
listen: { port: 7000 },
},
});
discovery = SingleHostDiscovery.fromConfig(config);
const router = await createRouter({
badgeBuilder,
catalog,
config,
discovery,
});
app = express().use(router);
});
@@ -83,7 +88,12 @@ describe('createRouter', () => {
});
it('works', async () => {
const router = await createRouter({ badgeBuilder, catalog, config });
const router = await createRouter({
badgeBuilder,
catalog,
config,
discovery,
});
expect(router).toBeDefined();
});
@@ -91,17 +101,15 @@ describe('createRouter', () => {
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),
);
badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
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(response.text).toEqual(JSON.stringify([badge], null, 2));
expect(catalog.getEntityByName).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByName).toHaveBeenCalledWith({
@@ -110,31 +118,30 @@ describe('createRouter', () => {
name: 'test',
});
expect(badgeBuilder.getBadgeIds).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadge).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadge).toHaveBeenCalledWith({
badgeId: badge.id,
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeJson).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeJson).toHaveBeenCalledWith({
badgeInfo: { id: badge.id },
context: {
badgeUrl: expect.stringMatching(
/http:\/\/127.0.0.1:\d+\/entity\/default\/service\/test\/test-badge/,
/http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/,
),
config,
entity,
},
format: 'json',
});
});
});
describe('GET /entity/:namespace/:kind/:name/test-badge', () => {
describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => {
it('returns badge for entity', async () => {
catalog.getEntityByName.mockResolvedValueOnce(entity);
const image = '<svg>...</svg>';
badgeBuilder.createBadge.mockResolvedValueOnce(image);
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
const response = await request(app).get(
'/entity/default/service/test/test-badge',
'/entity/default/service/test/badge/test-badge',
);
expect(response.status).toEqual(200);
@@ -147,18 +154,17 @@ describe('createRouter', () => {
name: 'test',
});
expect(badgeBuilder.getBadgeIds).toHaveBeenCalledTimes(0);
expect(badgeBuilder.createBadge).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadge).toHaveBeenCalledWith({
badgeId: badge.id,
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({
badgeInfo: { id: badge.id },
context: {
badgeUrl: expect.stringMatching(
/http:\/\/127.0.0.1:\d+\/entity\/default\/service\/test\/test-badge/,
/http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/,
),
config,
entity,
},
format: 'svg',
});
});
});
+64 -53
View File
@@ -31,17 +31,14 @@ export interface RouterOptions {
badgeFactories?: BadgeFactories;
catalog?: CatalogApi;
config: Config;
discovery?: PluginEndpointDiscovery;
discovery: PluginEndpointDiscovery;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.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! });
options.catalog || new CatalogClient({ discoveryApi: options.discovery });
const badgeBuilder =
options.badgeBuilder ||
new DefaultBadgeBuilder(options.badgeFactories || {});
@@ -56,70 +53,84 @@ export async function createRouter(
);
}
const context: BadgeContext = {
badgeUrl: '',
config: options.config,
entity,
};
const specs = [];
for (const badgeId of await badgeBuilder.getBadgeIds()) {
context.badgeUrl = [
`${req.protocol}://`,
req.headers.host,
req.originalUrl.replace(/badge-specs$/, badgeId),
].join('');
const badge = await badgeBuilder.createBadge({
badgeId,
context,
format: 'json',
});
for (const badgeInfo of await badgeBuilder.getBadges()) {
const context: BadgeContext = {
badgeUrl: await getBadgeUrl(
namespace,
kind,
name,
badgeInfo.id,
options,
),
config: options.config,
entity,
};
const badge = await badgeBuilder.createBadgeJson({ badgeInfo, context });
if (badge) {
specs.push(badge);
}
}
res.setHeader('Content-Type', 'application/json');
res.status(200).send(`[${specs.join(',\n')}]`);
res.status(200).send(JSON.stringify(specs, null, 2));
});
router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => {
const { namespace, kind, name, badgeId } = req.params;
const entity = await catalog.getEntityByName({ namespace, kind, name });
if (!entity) {
throw new NotFoundError(
`No ${kind} entity in ${namespace} named "${name}"`,
);
}
router.get(
'/entity/:namespace/:kind/:name/badge/:badgeId',
async (req, res) => {
const { namespace, kind, name, badgeId } = req.params;
const entity = await catalog.getEntityByName({ namespace, kind, name });
if (!entity) {
throw new NotFoundError(
`No ${kind} entity in ${namespace} named "${name}"`,
);
}
let format =
req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml';
if (req.query.format === 'json') {
format = 'application/json';
}
let format =
req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml';
if (req.query.format === 'json') {
format = 'application/json';
}
const badgeUrl = [
`${req.protocol}://`,
req.headers.host,
req.originalUrl,
].join('');
const badgeOptions = {
badgeInfo: { id: badgeId },
context: {
badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId, options),
config: options.config,
entity,
},
};
const data = await badgeBuilder.createBadge({
badgeId,
context: { badgeUrl, config: options.config, entity },
format: format === 'application/json' ? 'json' : 'svg',
});
let data: string;
if (format === 'application/json') {
data = JSON.stringify(
await badgeBuilder.createBadgeJson(badgeOptions),
null,
2,
);
} else {
data = await badgeBuilder.createBadgeSvg(badgeOptions);
}
if (!data) {
throw new NotFoundError(`Unknown badge "${badgeId}" for ${kind} entity.`);
}
res.setHeader('Content-Type', format);
res.status(200).send(data);
});
res.setHeader('Content-Type', format);
res.status(200).send(data);
},
);
router.use(errorHandler());
return router;
}
async function getBadgeUrl(
namespace: string,
kind: string,
name: string,
badgeId: string,
options: RouterOptions,
): Promise<string> {
const baseUrl = await options.discovery.getExternalBaseUrl('badges');
return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`;
}
+1 -1
View File
@@ -55,7 +55,7 @@ export interface BadgeContext {
}
export interface BadgeFactory {
createBadge(context: BadgeContext): Badge | null;
createBadge(context: BadgeContext): Badge;
}
export interface BadgeFactories {