diff --git a/app-config.yaml b/app-config.yaml index 7a4fc0a929..d4aa0694c8 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -406,30 +406,30 @@ pagerduty: badges: pingback: kind: 'entity' - description: 'Link back to $${app.title}' - target: '$${entity_url}' - label: 'View $${entity.metadata.name} in' - message: '$${app.title}' + description: 'Link back to _{app.title}' + target: '_{entity_url}' + label: 'View _{entity.metadata.name} in' + message: '_{app.title}' lifecycle: kind: 'entity' description: 'Entity lifecycle badge' - target: '$${entity_url}' + target: '_{entity_url}' label: 'Lifecycle' - message: '$${entity.spec.lifecycle}' + message: '_{entity.spec.lifecycle}' owner: kind: 'entity' title: 'Resource Owner' description: 'Entity owner badge' - target: '$${entity_url}' + target: '_{entity_url}' label: 'Owner' - message: '$${entity.spec.owner}' + message: '_{entity.spec.owner}' color: 'blue' docs: kind: 'entity' - target: '$${entity_url}/docs' + target: '_{entity_url}/docs' label: 'Documentation' - message: '$${entity.metadata.name}' + message: '_{entity.metadata.name}' color: 'navyblue' diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index fa3ccbc4eb..cbd2dcf0c7 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -1,10 +1,30 @@ # Badges Backend -Simple plugin for serving badges. +Backend plugin for serving badges. Default implementation uses +[badge-maker](https://www.npmjs.com/package/badge-maker) for creating +the badges, in svg. ## Setup -N/A +Define which badges to offer in the backend api by declaring them in +the app-config, under a `badges` key. Example: + +```yaml +badges: + docs: + kind: 'entity' + target: '_{entity_url}/docs' + label: 'Documentation' + message: '_{entity.metadata.name}' + color: 'navyblue' + + lifecycle: + kind: 'entity' + description: 'Entity lifecycle badge' + target: '_{entity_url}' + label: 'Lifecycle' + message: '_{entity.spec.lifecycle}' +``` ## Links diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts index 298c72be6e..6ec56daf23 100644 --- a/plugins/badges-backend/config.d.ts +++ b/plugins/badges-backend/config.d.ts @@ -20,7 +20,8 @@ export interface Config { * * The `label` and `message` fields may use templating to support * dynamic content, based on context. Use same syntax as for - * javascript template literals, but with double `$$`. + * javascript template literals, but with `_` instead of `$`, + * e.g. `_{context.variable.name}`. * */ badges?: { @@ -35,12 +36,13 @@ export interface Config { * Available badge kinds: * * * `entity` The entity data is available as `entity` in the template. + * * `entity_url` The (frontend) URL to view the entity in Backstage. * * Default context for all badges: * * * `app.title` As configured or defaults to "Backstage". * - * @visibility: frontend + * @visibility frontend */ kind?: 'entity'; @@ -51,14 +53,25 @@ export interface Config { label: string; /** - * The badge color. Default: `#36BAA2`. + * The badge message. + */ + message: string; + + /** + * The message color. Default: `#36BAA2`. */ color?: string; /** - * The badge message. + * The label color. Default: `gray`. */ - message: string; + labelColor?: string; + + /** + * Visual design of the badge. One of: 'plastic', 'flat', 'flat-square', + * 'for-the-badge' or 'social'. + */ + style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; }; }; } diff --git a/plugins/badges-backend/src/api/BadgesApi.ts b/plugins/badges-backend/src/api/BadgesApi.ts deleted file mode 100644 index 17613b4284..0000000000 --- a/plugins/badges-backend/src/api/BadgesApi.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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 { Logger } from 'winston'; -import { makeBadge, ValidationError } from 'badge-maker'; -import {} from './types'; -import { interpolate } from '../utils'; - -export interface BadgeConfig { - kind?: 'entity'; - label: string; - color: string; - message: string; -} - -export class BadgesApi { - constructor( - private readonly logger: Logger, - private readonly config: { [id: string]: BadgeConfig }, - ) {} - - public getBadge(badgeKind: string, badgeId: string, context: object) { - const badge = this.config[badgeId] || this.config.default; - - if (!badge) { - return makeBadge({ - label: 'Unknown badge ID', - message: badgeId, - color: 'red', - }); - } - - if (badge.kind && badge.kind !== badgeKind) { - return makeBadge({ - label: 'Invalid badge kind', - message: `${badgeId} is for ${badge.kind} not ${badgeKind}`, - color: 'red', - }); - } - - const svg = makeBadge({ - label: this.render(badge.label, context), - message: this.render(badge.message, context), - color: badge.color || '#36BAA2', - }); - - return svg; - } - - private render(template, context) { - try { - return interpolate(template.replace('$$', '$'), context); - } catch (err) { - return `${err} [${template}]`; - } - } -} diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts index d82b8a9c43..01917c0c80 100644 --- a/plugins/badges-backend/src/index.ts +++ b/plugins/badges-backend/src/index.ts @@ -14,6 +14,5 @@ * limitations under the License. */ -export * from './api'; export * from './service/router'; export * from './utils'; diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts new file mode 100644 index 0000000000..38a06ed0cd --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -0,0 +1,87 @@ +/* + * 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 { Logger } from 'winston'; +import { makeBadge, ValidationError } from 'badge-maker'; +import { BadgeBuilder, BadgeConfig, BadgeOptions } from './types'; +import { interpolate } from '../../utils'; + +export class DefaultBadgeBuilder implements BadgeBuilder { + constructor( + private readonly logger: Logger, + private readonly config: { [id: string]: BadgeConfig }, + ) {} + + public async getBadgeConfig(badgeId: string): BadgeConfig { + return ( + this.config[badgeId] || + this.config.default || { + label: 'Unknown badge ID', + message: badgeId, + color: 'red', + } + ); + } + + public async createBadge(options: BadgeOptions): string { + const { context, config: badge } = options; + const params = { + label: this.render(badge.label, context), + message: this.render(badge.message, context), + color: badge.color || '#36BAA2', + }; + + if (badge.labelColor) { + params.labelColor = badge.labelColor; + } + + if (badge.style) { + params.style = badge.style; + } + + switch (options.format) { + case 'json': + return JSON.stringify( + { + badge: params, + ...options, + }, + null, + 2, + ); + case 'svg': + try { + return makeBadge(params); + } catch (err) { + return makeBadge({ + label: 'Invalid badge parameters', + message: `${err}`, + color: 'red', + }); + } + default: + throw new TypeError(`unsupported badge format: ${options.format}`); + } + } + + private render(template: string, context: object): string { + try { + return interpolate(template.replace('_{', '${'), context); + } catch (err) { + return `${err} [${template}]`; + } + } +} diff --git a/plugins/badges-backend/src/api/index.ts b/plugins/badges-backend/src/lib/BadgeBuilder/index.ts similarity index 87% rename from plugins/badges-backend/src/api/index.ts rename to plugins/badges-backend/src/lib/BadgeBuilder/index.ts index 0a6328bd5c..20f18b8852 100644 --- a/plugins/badges-backend/src/api/index.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export { BadgesApi } from './BadgesApi'; +export { DefaultBadgeBuilder } from './DefaultBadgeBuilder'; +export * from './types'; diff --git a/plugins/badges-backend/src/api/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts similarity index 55% rename from plugins/badges-backend/src/api/types.ts rename to plugins/badges-backend/src/lib/BadgeBuilder/types.ts index 863d6e76e1..f58e35ec1d 100644 --- a/plugins/badges-backend/src/api/types.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -13,3 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export interface BadgeConfig { + kind?: 'entity'; + label: string; + message: string; + color?: string; + labelColor?: string; + style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; + links?: [string, string]; +} + +export type BadgeOptions = { + context: object; + config: BadgeConfig; + format: 'svg' | 'json'; +}; + +export type BadgeBuilder = { + createBadge(options: BadgeOptions): Promise; + getBadgeConfig(badgeId: string): Promise; +}; diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index cc470a33b8..d0013d8318 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -23,10 +23,10 @@ import { PluginEndpointDiscovery, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; -import { BadgesApi } from '../api'; +import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; export interface RouterOptions { - badgesApi?: BadgesApi; + badgeBuilder: BadgeBuilder; logger: Logger; config: Config; discovery: PluginEndpointDiscovery; @@ -36,27 +36,53 @@ export async function createRouter( options: RouterOptions, ): Promise { const router = Router(); - const logger = options.logger.child({ plugin: 'badges' }); - const badgesConfig = options.config.getOptional('badges') ?? {}; const title = options.config.getString('app.title') || 'Backstage'; - const badgesApi = options.badgesApi || new BadgesApi(logger, badgesConfig); + const catalogUrl = `${options.config.getString('app.baseUrl')}/catalog`; + const badgesConfig = options.config.getOptional('badges') ?? {}; + const badgeBuilder = + options.badgeBuilder || new DefaultBadgeBuilder(logger, badgesConfig); router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => { - const entity = await getEntity(logger, options.discovery, req.params); + const { badgeId } = req.params; + let badge = await badgeBuilder.getBadgeConfig(badgeId); + if (badge.kind && badge.kind !== 'entity') { + badge = { + label: 'Badge kind error', + message: `${badgeId} is for ${badge.kind} not entity`, + color: 'red', + }; + } + + const entityUri = getEntityUri(req.params); + const entity = await getEntity(logger, options.discovery, entityUri); if (!entity) { res.status(400).send(`Unknown entity`); return; } - const { badgeId } = req.params; - const badge = badgesApi.getBadge('entity', badgeId, { - app: { title }, - entity, + let format = + req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml'; + if (req.query.format === 'json') { + format = 'application/json'; + } + + if (req.query.style) { + badge.style = req.query.style; + } + + const data = await badgeBuilder.createBadge({ + config: badge, + context: { + app: { title }, + entity, + entity_url: `${catalogUrl}/${entityUri}`, + }, + format: format === 'application/json' ? 'json' : 'svg', }); - res.setHeader('Content-Type', 'image/svg+xml'); - res.status(200).send(badge); + res.setHeader('Content-Type', format); + res.status(200).send(data); }); router.use(errorHandler()); @@ -64,18 +90,22 @@ export async function createRouter( return router; } -async function getEntity(logger, discovery, params) { - const catalogUrl = await discovery.getBaseUrl('catalog'); +function getEntityUri(params) { const { kind, namespace, name } = params; + return `${kind}/${namespace}/${name}`; +} + +async function getEntity(logger, discovery, entityUri) { + const catalogUrl = await discovery.getBaseUrl('catalog'); try { const entity = (await ( - await fetch(`${catalogUrl}/entities/by-name/${kind}/${namespace}/${name}`) + await fetch(`${catalogUrl}/entities/by-name/${entityUri}`) ).json()) as Entity; return entity; } catch (err) { - const msg = `Unable to get entity ${kind}/${namespace}/${name}, error ${err}`; + const msg = `Unable to get entity ${entityUri}, error ${err}`; logger.info(msg); return null; }