diff --git a/app-config.yaml b/app-config.yaml index 465c7b0049..8bf6868353 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -401,3 +401,8 @@ homepage: timezone: 'Asia/Tokyo' pagerduty: eventsBaseUrl: 'https://events.pagerduty.com/v2' +badges: + pingback: + kind: 'entity' + label: 'View $${entity.metadata.name} in' + message: '$${app.title}' diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts index 961c94e1ce..132a90e38e 100644 --- a/packages/backend/src/plugins/badges.ts +++ b/packages/backend/src/plugins/badges.ts @@ -20,6 +20,7 @@ import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, + discovery, }: PluginEnvironment) { - return await createRouter({ logger, config }); + return await createRouter({ logger, config, discovery }); } diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts index 7470a9c58f..2fa3296104 100644 --- a/plugins/badges-backend/config.d.ts +++ b/plugins/badges-backend/config.d.ts @@ -15,7 +15,48 @@ */ export interface Config { - /** Configuration options for the badges-backend plugin */ - /* badges?: { - };*/ + /** + * Define all badges. + * + * 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 `$$`. + * + */ + badges?: { + [badgeId: string]: { + /** + * (Optional) Badge kind. + * + * Restrict badge usage to a certain kind only. + * Useful when using templating for label and/or message if they + * use context data only available for a certain kind of badge. + * + * Available badge kinds: + * + * * `entity` The entity data is available as `entity` in the template. + * + * Default context for all badges: + * + * * `app.title` As configured or defaults to "Backstage". + * + */ + kind?: 'entity'; + + /** + * The badge label. + */ + label: string; + + /** + * The badge color. Default: `#36BAA2`. + */ + color?: string; + + /** + * The badge message. + */ + message: string; + }; + }; } diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index dc78d37696..3d77acbe98 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -35,6 +35,7 @@ "@types/express": "^4.17.6", "badge-maker": "^3.3.0", "cors": "^2.8.5", + "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", "winston": "^3.2.1", diff --git a/plugins/badges-backend/src/api/BadgesApi.ts b/plugins/badges-backend/src/api/BadgesApi.ts index 2ce6bb5588..5dd68e852d 100644 --- a/plugins/badges-backend/src/api/BadgesApi.ts +++ b/plugins/badges-backend/src/api/BadgesApi.ts @@ -17,16 +17,54 @@ 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) {} + constructor( + private readonly logger: Logger, + private readonly config: { [id: string]: BadgeConfig }, + ) {} + + public getBadge(badgeKind: string, badgeId: string, context: any) { + 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', + }); + } - public getPoweredByBadge() { const svg = makeBadge({ - label: 'Powered By', - message: 'Backstage', - color: '#36BAA2', + 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/service/router.ts b/plugins/badges-backend/src/service/router.ts index 26c56f7f86..cc470a33b8 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -15,9 +15,13 @@ */ import express from 'express'; +import fetch from 'cross-fetch'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { errorHandler } from '@backstage/backend-common'; +import { + errorHandler, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { BadgesApi } from '../api'; @@ -25,6 +29,7 @@ export interface RouterOptions { badgesApi?: BadgesApi; logger: Logger; config: Config; + discovery: PluginEndpointDiscovery; } export async function createRouter( @@ -33,13 +38,22 @@ export async function createRouter( const router = Router(); const logger = options.logger.child({ plugin: 'badges' }); - // const config = options.config.getConfig('badges'); + const badgesConfig = options.config.getOptional('badges') ?? {}; + const title = options.config.getString('app.title') || 'Backstage'; + const badgesApi = options.badgesApi || new BadgesApi(logger, badgesConfig); - const badgesApi = options.badgesApi || new BadgesApi(logger); + router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => { + const entity = await getEntity(logger, options.discovery, req.params); + if (!entity) { + res.status(400).send(`Unknown entity`); + return; + } - router.get('/entity/:namespace/:kind/:name', async (req, res) => { - const { namespace, kind, name } = req.params; - const badge = badgesApi.getPoweredByBadge(); + const { badgeId } = req.params; + const badge = badgesApi.getBadge('entity', badgeId, { + app: { title }, + entity, + }); res.setHeader('Content-Type', 'image/svg+xml'); res.status(200).send(badge); @@ -49,3 +63,20 @@ export async function createRouter( return router; } + +async function getEntity(logger, discovery, params) { + const catalogUrl = await discovery.getBaseUrl('catalog'); + const { kind, namespace, name } = params; + + try { + const entity = (await ( + await fetch(`${catalogUrl}/entities/by-name/${kind}/${namespace}/${name}`) + ).json()) as Entity; + + return entity; + } catch (err) { + const msg = `Unable to get entity ${kind}/${namespace}/${name}, error ${err}`; + logger.info(msg); + return null; + } +} diff --git a/plugins/badges-backend/src/service/standaloneServer.ts b/plugins/badges-backend/src/service/standaloneServer.ts index 5108437726..d358edcbdc 100644 --- a/plugins/badges-backend/src/service/standaloneServer.ts +++ b/plugins/badges-backend/src/service/standaloneServer.ts @@ -19,6 +19,7 @@ import { Logger } from 'winston'; import { createServiceBuilder, loadBackendConfig, + SingleHostDiscovery, } from '@backstage/backend-common'; import { createRouter } from './router'; @@ -33,10 +34,11 @@ export async function startStandaloneServer( ): Promise { const logger = options.logger.child({ service: 'badges-backend' }); const config = await loadBackendConfig({ logger, argv: process.argv }); + const discovery = SingleHostDiscovery.fromConfig(config); logger.debug('Creating application...'); - const router = await createRouter({ logger, config }); + const router = await createRouter({ logger, config, discovery }); const service = createServiceBuilder(module) .enableCors({ origin: 'http://localhost:3000' }) diff --git a/plugins/badges-backend/src/utils.ts b/plugins/badges-backend/src/utils.ts new file mode 100644 index 0000000000..e9090905e3 --- /dev/null +++ b/plugins/badges-backend/src/utils.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * adapted from https://stackoverflow.com/a/41015840/444060 + */ +export function interpolate(template, params) { + const names = Object.keys(params); + const vals = Object.values(params); + // eslint-disable-next-line no-new-func + return new Function(...names, `return \`${template}\`;`)(...vals); +}