badges: load default badges from code rather than config. #4457.

Signed-off-by: Andreas Stenius <git@astekk.se>
This commit is contained in:
Andreas Stenius
2021-02-23 13:03:43 +01:00
committed by Fredrik Adelöw
parent ce4897ff37
commit ac17aeb35d
15 changed files with 278 additions and 206 deletions
+1 -28
View File
@@ -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
+2 -2
View File
@@ -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 });
}
+14 -13
View File
@@ -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;
};
+54
View File
@@ -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',
},
];
+2
View File
@@ -15,4 +15,6 @@
*/
export * from './service/router';
export * from './types';
export * from './utils';
export * from './badges';
@@ -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<BadgeConfig> {
public async getAllBadgeConfigs(): Promise<Badge[]> {
return Object.values(this.config) as Badge[];
}
public async getBadgeConfig(badgeId: string): Promise<Badge> {
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<string> {
@@ -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;
}
}
@@ -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<string>;
getBadgeConfig(badgeId: string): Promise<BadgeConfig>;
getBadgeConfig(badgeId: string): Promise<Badge>;
getAllBadgeConfigs(): Promise<Badge[]>;
};
+63 -9
View File
@@ -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',
});
+48
View File
@@ -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;
}
-27
View File
@@ -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';
};
};
}
+2 -4
View File
@@ -45,8 +45,6 @@
"msw": "^0.21.2"
},
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
"dist"
]
}
+13 -47
View File
@@ -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<Badge[]> {
const badges = [];
const badgesConfig = this.configApi.getOptional('badges') ?? {};
const entityBadgeUri = await this.getEntityBadgeUri(entity);
public async getEntityBadgeSpecs(entity: Entity): Promise<BadgeSpec[]> {
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<Badge> {
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<string> {
private async getEntityBadgeSpecsUrl(entity: Entity): Promise<string> {
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})`;
}
}
+25 -18
View File
@@ -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<Badge[]>;
getEntityBadgeSpecs(entity: Entity): Promise<BadgeSpec[]>;
}
@@ -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 }) => (
<div key={id}>
<DialogContentText>
{spec.badge.title || spec.badge.description || id}
<br />
<img alt={spec.badge.description} src={url} />
</DialogContentText>
<Typography component="div" className={classes.codeBlock}>
Copy the following snippet of markdown code for the badge:
<CodeSnippet language="markdown" text={markdown} showCopyCodeButton />
</Typography>
<hr />
</div>
));
const content = (badges || []).map(
({
badge: { description, markdown },
config: { id },
context: { badge_url },
}) => (
<div key={id}>
<DialogContentText>
{description}
<br />
<img alt={description} src={badge_url} />
</DialogContentText>
<Typography component="div" className={classes.codeBlock}>
Copy the following snippet of markdown code for the badge:
<CodeSnippet language="markdown" text={markdown} showCopyCodeButton />
</Typography>
<hr />
</div>
),
);
return (
<Dialog fullScreen={fullScreen} open={open} onClose={onClose}>
+2 -4
View File
@@ -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 }),
}),
],
});