diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index abe8884600..22354cb444 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -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 diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 470e718b3f..5c395bf3ea 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -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", diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts index dc08144706..240bce56af 100644 --- a/plugins/badges-backend/src/index.ts +++ b/plugins/badges-backend/src/index.ts @@ -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'; diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts new file mode 100644 index 0000000000..9fc9bcf32d --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts @@ -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; + 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>$/)); + }); + + 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})`, + }); + }); + }); +}); diff --git a/plugins/badges-backend/src/utils.ts b/plugins/badges-backend/src/lib/index.ts similarity index 64% rename from plugins/badges-backend/src/utils.ts rename to plugins/badges-backend/src/lib/index.ts index 0f4773170e..47cccec8f8 100644 --- a/plugins/badges-backend/src/utils.ts +++ b/plugins/badges-backend/src/lib/index.ts @@ -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'; diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 72400b50b5..c05a3c9c58 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -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; + let catalog: jest.Mocked; + let config: jest.Mocked; + + 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 = '...'; + 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', + }); + }); + }); }); diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 0289f39148..86caf793d5 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -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 { - 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 { - const catalogUrl = await discovery.getBaseUrl('catalog'); - - const entity = (await ( - await fetch(`${catalogUrl}/entities/by-name/${entityUri}`) - ).json()) as Entity; - - return entity; -} diff --git a/plugins/badges/README.md b/plugins/badges/README.md index 221d3a0e90..09e00c79fe 100644 --- a/plugins/badges/README.md +++ b/plugins/badges/README.md @@ -5,10 +5,13 @@ your backstage deployment, showing information related to data from the catalog, such as entity owner and lifecycle data for instance. The available badges are setup in the `badges-backend` plugin, see -link below. +link below for more details. -To get markdown code for the badges, access the `Badges` context menu -(three dots in the upper right corner) of an entity page. +## Entity badges + +To get markdown code for the entity badges, access the `Badges` context menu +(three dots in the upper right corner) of an entity page, which will popup a +badges dialog showing all available badges for that entity. ## Links