diff --git a/app-config.yaml b/app-config.yaml index 2cab686e96..21b3c3199d 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -404,37 +404,10 @@ pagerduty: # sample badges badges: - pingback: - kind: 'entity' - title: 'View _{entity.metadata.name} in _{app.title}' - description: 'Link back to _{app.title}' - link: '_{entity_url}' - label: '_{app.title}' - message: '_{entity.kind}: _{entity.metadata.name}' - style: flat-square - lifecycle: kind: 'entity' description: 'Entity lifecycle badge' link: '_{entity_url}' - label: 'Lifecycle' + label: 'lifecycle' message: '_{entity.spec.lifecycle}' style: for-the-badge - - owner: - kind: 'entity' - title: 'Resource Owner' - description: 'Entity owner badge' - link: '_{entity_url}' - label: 'Owner' - message: '_{entity.spec.owner}' - color: 'blue' - style: social - - docs: - kind: 'entity' - link: '_{entity_url}/docs' - label: 'Documentation' - message: '_{entity.metadata.name}' - color: 'navyblue' - style: plastic diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts index 132a90e38e..4e2eee74e5 100644 --- a/packages/backend/src/plugins/badges.ts +++ b/packages/backend/src/plugins/badges.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createRouter } from '@backstage/plugin-badges-backend'; +import { createRouter, badges } from '@backstage/plugin-badges-backend'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ @@ -22,5 +22,5 @@ export default async function createPlugin({ config, discovery, }: PluginEnvironment) { - return await createRouter({ logger, config, discovery }); + return await createRouter({ logger, config, discovery, badges }); } diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts index b13f46e1fa..5870b650a0 100644 --- a/plugins/badges-backend/config.d.ts +++ b/plugins/badges-backend/config.d.ts @@ -16,7 +16,10 @@ export interface Config { /** - * Define all badges. + * Define custom badges. By default, the badges are declared in + * code, and passed to the badges backend `createRouter`, which + * merges them with any additional badges defined in this + * configuration. * * The `label` and `message` fields may use templating to support * dynamic content, based on context. Use same syntax as for @@ -33,7 +36,9 @@ export interface Config { * 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: + * Context per badge kind. + * + * Entity badges: * * * `entity` The entity data is available as `entity` in the template. * * `entity_url` The (frontend) URL to view the entity in Backstage. @@ -41,14 +46,13 @@ export interface Config { * Default context for all badges: * * * `app.title` As configured or defaults to "Backstage". + * * `badge_url` The URL to the badge image. * - * @visibility frontend */ kind?: 'entity'; /** * The badge label. - * */ label: string; @@ -70,21 +74,17 @@ export interface Config { /** * Visual design of the badge. One of: 'plastic', 'flat', 'flat-square', * 'for-the-badge' or 'social'. + * + * Default: 'flat' + * */ style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; /** - * Badge title, used as tooltip text in the markdown code. + * Badge description, used as prefix on the alt text in the markdown code. * - * @visibility frontend - */ - title?: string; - - /** - * Badge description, used as alt text in the markdown code. * Defaults to badge id. * - * @visibility frontend */ description?: string; @@ -94,7 +94,8 @@ export interface Config { * For `entity` badges, there is a `entity_url` in the context * that could be appropriate to use here. * - * @visibility frontend + * Defaults to the `entity_url`, set to falsey value to disable the link. + * */ link?: string; }; diff --git a/plugins/badges-backend/src/badges.ts b/plugins/badges-backend/src/badges.ts new file mode 100644 index 0000000000..253b6c0166 --- /dev/null +++ b/plugins/badges-backend/src/badges.ts @@ -0,0 +1,54 @@ +/* + * 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 { Badge } from './types'; + +export const badges: Badge[] = [ + { + id: 'pingback', + kind: 'entity', + description: 'Link back to _{app.title}', + label: '_{app.title}', + message: '_{entity.kind}: _{entity.metadata.name}', + style: 'flat-square', + }, + { + id: 'lifecycle', + kind: 'entity', + description: 'Entity lifecycle badge', + label: 'lifecycle', + message: '_{entity.spec.lifecycle}', + style: 'flat-square', + }, + { + id: 'owner', + kind: 'entity', + description: 'Entity owner badge', + label: 'owner', + message: '_{entity.spec.owner}', + color: 'blue', + style: 'flat-square', + }, + { + id: 'docs', + kind: 'entity', + link: '_{entity_url}/docs', + label: 'docs', + message: '_{entity.metadata.name}', + color: 'navyblue', + style: 'flat-square', + }, +]; diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts index 01917c0c80..dc08144706 100644 --- a/plugins/badges-backend/src/index.ts +++ b/plugins/badges-backend/src/index.ts @@ -15,4 +15,6 @@ */ export * from './service/router'; +export * from './types'; export * from './utils'; +export * from './badges'; diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index 1e96b6ce5b..dad928e8ec 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -17,28 +17,31 @@ import { Logger } from 'winston'; import { makeBadge } from 'badge-maker'; import { JsonObject } from '@backstage/config'; -import { - BadgeBuilder, - BadgeConfig, - BadgeOptions, - BadgeStyle, - BadgeStyles, -} from './types'; +import { BadgeBuilder, BadgeOptions } from './types'; +import { Badge, BadgeStyle, BadgeStyles } from '../../types'; import { interpolate } from '../../utils'; export class DefaultBadgeBuilder implements BadgeBuilder { constructor( private readonly logger: Logger, private readonly config: JsonObject, - ) {} + ) { + for (const [badgeId, badge] of Object.entries(config)) { + badge.id = badgeId; + } + } - public async getBadgeConfig(badgeId: string): Promise { + public async getAllBadgeConfigs(): Promise { + return Object.values(this.config) as Badge[]; + } + + public async getBadgeConfig(badgeId: string): Promise { return ((this.config[badgeId] as unknown) || (this.config.default as unknown) || { label: 'Unknown badge ID', message: badgeId, color: 'red', - }) as BadgeConfig; + }) as Badge; } public async createBadge(options: BadgeOptions): Promise { @@ -47,7 +50,7 @@ export class DefaultBadgeBuilder implements BadgeBuilder { label: this.render(badge.label, context), message: this.render(badge.message, context), color: badge.color || '#36BAA2', - } as BadgeConfig; + } as Badge; if (badge.labelColor) { params.labelColor = badge.labelColor; @@ -59,15 +62,15 @@ export class DefaultBadgeBuilder implements BadgeBuilder { switch (options.format) { case 'json': - if (badge.title) { - params.title = this.render(badge.title, context); - } - if (badge.description) { - params.description = this.render(badge.description, context); - } if (badge.link) { params.link = this.render(badge.link, context); } + + params.description = badge.description + ? this.render(badge.description, context) + : badge.id; + params.markdown = this.getMarkdownCode(params, context.badge_url); + return JSON.stringify( { badge: params, @@ -101,4 +104,11 @@ export class DefaultBadgeBuilder implements BadgeBuilder { return `${err} [${template}]`; } } + + private getMarkdownCode(params: Badge, badge_url: string): string { + const alt_text = `${params.description}, ${params.label}: ${params.message}`; + const tooltip = params.description ? ` "${params.description}"` : ''; + const img = `![${alt_text}](${badge_url}${tooltip})`; + return params.link ? `[${img}](${params.link})` : img; + } } diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts index 0b0bc3d3b0..d082f4f843 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -14,34 +14,16 @@ * limitations under the License. */ -export const BadgeStyles = [ - 'plastic', - 'flat', - 'flat-square', - 'for-the-badge', - 'social', -] as const; -export type BadgeStyle = typeof BadgeStyles[number]; - -export interface BadgeConfig { - kind?: 'entity'; - label: string; - message: string; - color?: string; - labelColor?: string; - style?: BadgeStyle; - title?: string; - description?: string; - link?: string; -} +import { Badge } from '../../types'; export type BadgeOptions = { context: object; - config: BadgeConfig; + config: Badge; format: 'svg' | 'json'; }; export type BadgeBuilder = { createBadge(options: BadgeOptions): Promise; - getBadgeConfig(badgeId: string): Promise; + getBadgeConfig(badgeId: string): Promise; + getAllBadgeConfigs(): Promise; }; diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 81e9be069a..a7e3c018e0 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -24,15 +24,12 @@ import { } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { Config, JsonObject } from '@backstage/config'; -import { - BadgeBuilder, - BadgeStyle, - BadgeStyles, - DefaultBadgeBuilder, -} from '../lib/BadgeBuilder'; +import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; +import { Badge, BadgeStyle, BadgeStyles } from '../types'; export interface RouterOptions { badgeBuilder?: BadgeBuilder; + badges?: Badge[]; logger: Logger; config: Config; discovery: PluginEndpointDiscovery; @@ -47,10 +44,58 @@ export async function createRouter( const catalogUrl = `${options.config.getString('app.baseUrl')}/catalog`; const badgesConfig = (options.config.getOptional('badges') ?? {}) as JsonObject; + + for (const badge of options.badges || []) { + if (!badge.id) { + logger.warning(`badge without "id": ${JSON.stringify(badge, null, 2)}`); + } else if (!badgesConfig[badge.id]) { + badgesConfig[badge.id] = badge; + logger.info(`register builtin badge: ${badge.id}`); + } else { + logger.info(`builtin badge replaced from configuration: ${badge.id}`); + } + } + const badgeBuilder = options.badgeBuilder || new DefaultBadgeBuilder(logger, badgesConfig); - logger.debug(`loading badges`); + router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => { + const entityUri = getEntityUri(req.params); + const entity_url = `${catalogUrl}/${entityUri}`; + const entity = await getEntity(options.discovery, entityUri); + if (!entity) { + res.status(404).send(`Unknown entity`); + return; + } + + const context = { + app: { title }, + entity, + entity_url, + }; + + const specs = []; + for (const badge of await badgeBuilder.getAllBadgeConfigs()) { + if (!badge.kind || badge.kind === 'entity') { + badge.link = badge.link ?? '_{entity_url}'; + context.badge_url = [ + `${req.protocol}://`, + req.headers.host, + req.originalUrl.replace(/badge-specs$/, badge.id), + ].join(''); + specs.push( + await badgeBuilder.createBadge({ + config: badge, + context, + format: 'json', + }), + ); + } + } + + res.setHeader('Content-Type', 'application/json'); + res.status(200).send(`[${specs.join(',\n')}]`); + }); router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => { const { badgeId } = req.params; @@ -66,7 +111,7 @@ export async function createRouter( const entityUri = getEntityUri(req.params); const entity = await getEntity(options.discovery, entityUri); if (!entity) { - res.status(400).send(`Unknown entity`); + res.status(404).send(`Unknown entity`); return; } @@ -80,12 +125,21 @@ export async function createRouter( badge.style = req.query.style as BadgeStyle; } + const badge_url = [ + `${req.protocol}://`, + req.headers.host, + req.originalUrl, + ].join(''); + const entity_url = `${catalogUrl}/${entityUri}`; + badge.link = badge.link ?? '_{entity_url}'; + const data = await badgeBuilder.createBadge({ config: badge, context: { app: { title }, + badge_url, entity, - entity_url: `${catalogUrl}/${entityUri}`, + entity_url, }, format: format === 'application/json' ? 'json' : 'svg', }); diff --git a/plugins/badges-backend/src/types.ts b/plugins/badges-backend/src/types.ts new file mode 100644 index 0000000000..9ea720f609 --- /dev/null +++ b/plugins/badges-backend/src/types.ts @@ -0,0 +1,48 @@ +/* + * 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. + */ + +export const BadgeStyles = [ + 'plastic', + 'flat', + 'flat-square', + 'for-the-badge', + 'social', +] as const; +export type BadgeStyle = typeof BadgeStyles[number]; + +export interface Badge { + /** Unique name for the badge. */ + id?: string; + /** Badge message background color. */ + color?: string; + /** Badge description (tooltip text) */ + description?: string; + /** Kind of badge (in what context may it be used) */ + kind?: 'entity'; + /** + * Badge label (should be a rather static value) + * ref. shields spec https://github.com/badges/shields/blob/master/spec/SPECIFICATION.md + */ + label: string; + /** Badge label background color */ + labelColor?: string; + /** Custom badge link */ + link?: string; + /** Badge message */ + message: string; + /** Badge style (apperance). One of "plastic", "flat", "flat-square", "for-the-badge" and "social" */ + style?: BadgeStyle; +} diff --git a/plugins/badges/config.d.ts b/plugins/badges/config.d.ts deleted file mode 100644 index 000eaa0b9b..0000000000 --- a/plugins/badges/config.d.ts +++ /dev/null @@ -1,27 +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. - */ - -export interface Config { - // defined with doc in badges-backend - badges?: { - [badgeId: string]: { - /** - * @visibility frontend - */ - kind?: 'entity'; - }; - }; -} diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 39a220a534..65e09e2524 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -45,8 +45,6 @@ "msw": "^0.21.2" }, "files": [ - "dist", - "config.d.ts" - ], - "configSchema": "config.d.ts" + "dist" + ] } diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index 2cd68ec909..4160278d10 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -15,58 +15,33 @@ */ import { generatePath } from 'react-router'; -import { ConfigApi, DiscoveryApi } from '@backstage/core'; +import { DiscoveryApi } from '@backstage/core'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { entityRoute } from '@backstage/plugin-catalog-react'; -import { Badge, BadgesApi, BadgeConfig, BadgeSpec } from './types'; +import { BadgesApi, BadgeSpec } from './types'; export class BadgesClient implements BadgesApi { - private readonly configApi: ConfigApi; private readonly discoveryApi: DiscoveryApi; - constructor(options: { configApi: ConfigApi; discoveryApi: DiscoveryApi }) { - this.configApi = options.configApi; + constructor(options: { discoveryApi: DiscoveryApi }) { this.discoveryApi = options.discoveryApi; } - public async getDefinedEntityBadges(entity: Entity): Promise { - const badges = []; - const badgesConfig = this.configApi.getOptional('badges') ?? {}; - const entityBadgeUri = await this.getEntityBadgeUri(entity); + public async getEntityBadgeSpecs(entity: Entity): Promise { + const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity); + const specs = (await ( + await fetch(entityBadgeSpecsUrl) + ).json()) as BadgeSpec[]; - for (const [badgeId, badge] of Object.entries(badgesConfig)) { - if (!badge.kind || badge.kind === 'entity') { - const info = await this.getBadgeInfo(entityBadgeUri, badgeId); - if (info) { - badges.push(info); - } - } - } - - return badges; + return specs; } - private async getBadgeInfo( - entityBadgeUri: string, - badgeId: string, - ): Promise { - const badgeUrl = `${entityBadgeUri}/${badgeId}`; - const spec = (await ( - await fetch(`${badgeUrl}?format=json`) - ).json()) as BadgeSpec; - - return { - id: badgeId, - markdown: this.getBadgeMarkdownCode(badgeUrl, spec.badge), - spec, - url: badgeUrl, - }; - } - - private async getEntityBadgeUri(entity: Entity): Promise { + private async getEntityBadgeSpecsUrl(entity: Entity): Promise { const routeParams = this.getEntityRouteParams(entity); const path = generatePath(entityRoute.path, routeParams); - return `${await this.discoveryApi.getBaseUrl('badges')}/entity/${path}`; + return `${await this.discoveryApi.getBaseUrl( + 'badges', + )}/entity/${path}/badge-specs`; } private getEntityRouteParams(entity: Entity) { @@ -77,13 +52,4 @@ export class BadgesClient implements BadgesApi { name: entity.metadata.name, }; } - - private getBadgeMarkdownCode(badgeUrl: string, badge: BadgeConfig): string { - const title = badge.title ? ` "${badge.title}"` : ''; - const img = `![${badge.description || 'badge'}](${badgeUrl}${title})`; - if (!badge.link) { - return img; - } - return `[${img}](${badge.link})`; - } } diff --git a/plugins/badges/src/api/types.ts b/plugins/badges/src/api/types.ts index 00f5f62a3b..d3ba234e44 100644 --- a/plugins/badges/src/api/types.ts +++ b/plugins/badges/src/api/types.ts @@ -29,35 +29,42 @@ export type BadgeStyle = | 'for-the-badge' | 'social'; -// should probably have this in a "badges-common" package -export interface BadgeConfig { +interface BadgeParams { + color?: string; + description?: string; kind?: 'entity'; label: string; - message: string; - color?: string; labelColor?: string; - style?: BadgeStyle; - title?: string; - description?: string; link?: string; + message: string; + style?: BadgeStyle; +} + +interface Badge extends BadgeParams { + markdown: string; +} + +interface BadgeConfig extends BadgeParams { + id: string; } export interface BadgeSpec { - /** The rendered data */ - badge: BadgeConfig; + /** The rendered fields, markdown code */ + badge: Badge; + /** The configuration data, with placeholders and all */ config: BadgeConfig; - /** The context used when rendering config -> badge */ - context: object; -} -export interface Badge { - id: string; - markdown: string; - spec: BadgeSpec; - url: string; + /** The context used when rendering config -> badge */ + context: { + // here is more, but only badge_url we care about + badge_url: string; + }; + + format: 'json'; // or 'svg', but we'll never see that as structured + // data, only as an svg element } export interface BadgesApi { - getDefinedEntityBadges(entity: Entity): Promise; + getEntityBadgeSpecs(entity: Entity): Promise; } diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx index 3a667fb338..4fac612ec4 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -55,26 +55,32 @@ export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { const { value: badges, loading, error } = useAsync(async () => { if (open) { - return await badgesApi.getDefinedEntityBadges(entity); + return await badgesApi.getEntityBadgeSpecs(entity); } return []; }, [badgesApi, entity, open]); - const content = (badges || []).map(({ id, markdown, spec, url }) => ( -
- - {spec.badge.title || spec.badge.description || id} -
- {spec.badge.description} -
- - Copy the following snippet of markdown code for the badge: - - -
-
- )); + const content = (badges || []).map( + ({ + badge: { description, markdown }, + config: { id }, + context: { badge_url }, + }) => ( +
+ + {description} +
+ {description} +
+ + Copy the following snippet of markdown code for the badge: + + +
+
+ ), + ); return ( diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index 8a617171de..92932aa356 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -17,7 +17,6 @@ import { createApiFactory, createComponentExtension, createPlugin, - configApiRef, discoveryApiRef, } from '@backstage/core'; import { badgesApiRef, BadgesClient } from './api'; @@ -27,9 +26,8 @@ export const badgesPlugin = createPlugin({ apis: [ createApiFactory({ api: badgesApiRef, - deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, - factory: ({ configApi, discoveryApi }) => - new BadgesClient({ configApi, discoveryApi }), + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new BadgesClient({ discoveryApi }), }), ], });