badges: refactoring to use badge factories to create dynamic badges.

Signed-off-by: Andreas Stenius <andreas.stenius@svenskaspel.se>
This commit is contained in:
Andreas Stenius
2021-03-04 14:05:31 +01:00
committed by Fredrik Adelöw
parent ad95bebc8f
commit 5a09c681d7
10 changed files with 171 additions and 207 deletions
+2 -4
View File
@@ -16,19 +16,17 @@
import {
createRouter,
createDefaultBadges,
createDefaultBadgeFactories,
} from '@backstage/plugin-badges-backend';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
config,
discovery,
}: PluginEnvironment) {
return await createRouter({
logger,
config,
discovery,
badges: createDefaultBadges(),
badgeFactories: createDefaultBadgeFactories(),
});
}
+77 -31
View File
@@ -14,39 +14,85 @@
* limitations under the License.
*/
import { Badge } from './types';
import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { Badge, BadgeContext, BadgeFactories } from './types';
export const createDefaultBadges = (): Badge[] => [
{
id: 'pingback',
kind: 'entity',
description: 'Link to _{entity.metadata.name} in _{app.title}',
label: '_{entity.kind}',
message: '_{entity.metadata.name}',
style: 'flat-square',
function appTitle(context: BadgeContext): string {
return context.config.getString('app.title') || 'Backstage';
}
function entityUrl(context: BadgeContext): string {
const e = context.entity!;
const entityUri = `${e.kind}/${
e.metadata.namespace || ENTITY_DEFAULT_NAMESPACE
}/${e.metadata.name}`;
const catalogUrl = `${context.config.getString('app.baseUrl')}/catalog`;
return `${catalogUrl}/${entityUri}`;
}
export const createDefaultBadgeFactories = (): BadgeFactories => ({
pingback: {
createBadge: (context: BadgeContext): Badge | null => {
if (!context.entity) {
return null;
}
return {
description: `Link to ${context.entity.metadata.name} in ${appTitle(
context,
)}`,
kind: 'entity',
label: context.entity.kind,
link: entityUrl(context),
message: context.entity.metadata.name,
style: 'flat-square',
};
},
},
{
id: 'lifecycle',
kind: 'entity',
description: 'Entity lifecycle badge',
label: 'lifecycle',
message: '_{entity.spec.lifecycle}',
style: 'flat-square',
lifecycle: {
createBadge: (context: BadgeContext): Badge | null => {
if (!context.entity) {
return null;
}
return {
description: 'Entity lifecycle badge',
kind: 'entity',
label: 'lifecycle',
link: entityUrl(context),
message: `${context.entity.spec?.lifecycle || 'unknown'}`,
style: 'flat-square',
};
},
},
{
id: 'owner',
kind: 'entity',
description: 'Entity owner badge',
label: 'owner',
message: '_{entity.spec.owner}',
style: 'flat-square',
owner: {
createBadge: (context: BadgeContext): Badge | null => {
if (!context.entity) {
return null;
}
return {
description: 'Entity owner badge',
kind: 'entity',
label: 'owner',
link: entityUrl(context),
message: `${context.entity.spec?.owner || 'unknown'}`,
style: 'flat-square',
};
},
},
{
id: 'docs',
kind: 'entity',
link: '_{entity_url}/docs',
label: 'docs',
message: '_{entity.metadata.name}',
style: 'flat-square',
docs: {
createBadge: (context: BadgeContext): Badge | null => {
if (!context.entity) {
return null;
}
return {
kind: 'entity',
label: 'docs',
link: `${entityUrl(context)}/docs`,
message: context.entity.metadata.name,
style: 'flat-square',
};
},
},
];
});
@@ -14,83 +14,56 @@
* limitations under the License.
*/
import { Logger } from 'winston';
import { makeBadge } from 'badge-maker';
import { makeBadge, Format } from 'badge-maker';
import { BadgeBuilder, BadgeOptions } from './types';
import { Badge, BadgeConfig, BadgeStyle, BADGE_STYLES } from '../../types';
import { interpolate } from '../../utils';
import { Badge, BadgeFactories } from '../../types';
export class DefaultBadgeBuilder implements BadgeBuilder {
private readonly badges: BadgeConfig = {};
constructor(private readonly factories: BadgeFactories) {}
constructor(private readonly logger: Logger, initBadges: Badge[]) {
for (const badge of initBadges) {
if (!badge.id) {
logger.warning(`badge without "id": ${JSON.stringify(badge, null, 2)}`);
} else {
this.badges[badge.id] = badge;
logger.info(`register ${badge.kind || 'entity'} badge: "${badge.id}"`);
}
}
}
public async getAllBadgeConfigs(): Promise<Badge[]> {
return Object.values(this.badges);
}
public async getBadgeConfig(badgeId: string): Promise<Badge> {
return (
this.badges[badgeId] ||
this.badges.default ||
({
label: 'Unknown badge ID',
message: badgeId,
color: 'red',
} as Badge)
);
public async getBadgeIds(): Promise<string[]> {
return Object.keys(this.factories);
}
public async createBadge(options: BadgeOptions): Promise<string> {
const { context, config: badge } = options;
const params = {
label: this.render(badge.label, context),
message: this.render(badge.message, context),
color: badge.color || '#36BAA2',
} as Badge;
const factory = this.factories[options.badgeId];
const badge = factory
? factory.createBadge(options.context)
: ({
label: 'unknown badge',
message: options.badgeId,
color: 'red',
} as Badge);
if (badge.labelColor) {
params.labelColor = badge.labelColor;
}
if (BADGE_STYLES.includes(badge.style as BadgeStyle)) {
params.style = badge.style as BadgeStyle;
if (!badge) {
return '';
}
switch (options.format) {
case 'json':
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,
...options,
badge,
id: options.badgeId,
url: options.context.badgeUrl,
markdown: this.getMarkdownCode(badge, options.context.badgeUrl),
},
null,
2,
);
case 'svg':
try {
return makeBadge(params);
const format = {
message: badge.message,
color: badge.color || '#36BAA2',
label: badge.label || '',
labelColor: badge.labelColor || '',
style: badge.style || 'flat-square',
} as Format;
return makeBadge(format);
} catch (err) {
return makeBadge({
label: 'Invalid badge parameters',
label: 'invalid badge',
message: `${err}`,
color: 'red',
});
@@ -100,20 +73,9 @@ export class DefaultBadgeBuilder implements BadgeBuilder {
}
}
private render(template: string, context: object): string {
try {
return interpolate(template.replace(/_{/g, '${'), context).toLowerCase();
} catch (err) {
this.logger.info(
`badge template error: ${err}. In template: "${template}"`,
);
return `${err} [${template}]`;
}
}
private getMarkdownCode(params: Badge, badge_url: string): string {
let alt_text = `${params.label}: ${params.message}`;
if (params.description !== params.label) {
if (params.description && params.description !== params.label) {
alt_text = `${params.description}, ${alt_text}`;
}
const tooltip = params.description ? ` "${params.description}"` : '';
@@ -14,24 +14,15 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Badge } from '../../types';
import { BadgeContext } from '../../types';
export type BadgeOptions = {
context: {
app?: {
title: string;
};
entity?: Entity;
entity_url?: string;
badge_url?: string;
};
config: Badge;
badgeId: string;
context: BadgeContext;
format: 'svg' | 'json';
};
export type BadgeBuilder = {
createBadge(options: BadgeOptions): Promise<string>;
getBadgeConfig(badgeId: string): Promise<Badge>;
getAllBadgeConfigs(): Promise<Badge[]>;
getBadgeIds(): Promise<string[]>;
};
@@ -26,11 +26,7 @@ describe('createRouter', () => {
const logger = winston.createLogger();
const config = await loadBackendConfig({ logger, argv: [] });
const discovery = SingleHostDiscovery.fromConfig(config);
const router = await createRouter({
config,
logger,
discovery,
});
const router = await createRouter({ config, discovery });
expect(router).toBeDefined();
});
});
+29 -52
View File
@@ -17,7 +17,6 @@
import express from 'express';
import fetch from 'cross-fetch';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import {
errorHandler,
PluginEndpointDiscovery,
@@ -25,12 +24,11 @@ import {
import { Entity } from '@backstage/catalog-model';
import { Config, JsonObject } from '@backstage/config';
import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder';
import { Badge, BadgeStyle, BADGE_STYLES } from '../types';
import { BadgeContext, BadgeFactories } from '../types';
export interface RouterOptions {
badgeBuilder?: BadgeBuilder;
badges?: Badge[];
logger: Logger;
badgeFactories?: BadgeFactories;
config: Config;
discovery: PluginEndpointDiscovery;
}
@@ -39,45 +37,39 @@ export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = Router();
const logger = options.logger.child({ plugin: 'badges' });
const title = options.config.getString('app.title') || 'Backstage';
const catalogUrl = `${options.config.getString('app.baseUrl')}/catalog`;
const badgeBuilder =
options.badgeBuilder ||
new DefaultBadgeBuilder(logger, options.badges || []);
new DefaultBadgeBuilder(options.badgeFactories || {});
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 },
badge_url: '',
const context: BadgeContext = {
badgeUrl: '',
config: options.config,
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',
}),
);
for (const badgeId of await badgeBuilder.getBadgeIds()) {
context.badgeUrl = [
`${req.protocol}://`,
req.headers.host,
req.originalUrl.replace(/badge-specs$/, badgeId),
].join('');
const badge = await badgeBuilder.createBadge({
badgeId,
context,
format: 'json',
});
if (badge) {
specs.push(badge);
}
}
@@ -87,14 +79,6 @@ export async function createRouter(
router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => {
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(options.discovery, entityUri);
@@ -109,31 +93,24 @@ export async function createRouter(
format = 'application/json';
}
if (BADGE_STYLES.includes(req.query.style as BadgeStyle)) {
badge.style = req.query.style as BadgeStyle;
}
const badge_url = [
const badgeUrl = [
`${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,
},
badgeId,
context: { badgeUrl, config: options.config, entity },
format: format === 'application/json' ? 'json' : 'svg',
});
res.setHeader('Content-Type', format);
res.status(200).send(data);
if (!data) {
res.status(404).send(`Unknown entity badge "${badgeId}"`);
} else {
res.setHeader('Content-Type', format);
res.status(200).send(data);
}
});
router.use(errorHandler());
@@ -38,7 +38,7 @@ export async function startStandaloneServer(
logger.debug('Creating application...');
const router = await createRouter({ logger, config, discovery });
const router = await createRouter({ config, discovery });
const service = createServiceBuilder(module)
.enableCors({ origin: 'http://localhost:3000' })
+16 -7
View File
@@ -14,6 +14,9 @@
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
export const BADGE_STYLES = [
'plastic',
'flat',
@@ -24,13 +27,11 @@ export const BADGE_STYLES = [
export type BadgeStyle = typeof BADGE_STYLES[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 of badge */
kind?: 'entity';
/**
* Badge label (should be a rather static value)
@@ -45,10 +46,18 @@ export interface Badge {
message: string;
/** Badge style (apperance). One of "plastic", "flat", "flat-square", "for-the-badge" and "social" */
style?: BadgeStyle;
/** (generated) markdown code */
markdown?: string;
}
export interface BadgeConfig {
[id: string]: Badge;
export interface BadgeContext {
badgeUrl: string;
config: Config;
entity?: Entity; // for entity badges
}
export interface BadgeFactory {
createBadge(context: BadgeContext): Badge | null;
}
export interface BadgeFactories {
[id: string]: BadgeFactory;
}
+9 -20
View File
@@ -29,7 +29,7 @@ export type BadgeStyle =
| 'for-the-badge'
| 'social';
interface BadgeParams {
interface Badge {
color?: string;
description?: string;
kind?: 'entity';
@@ -40,29 +40,18 @@ interface BadgeParams {
style?: BadgeStyle;
}
interface Badge extends BadgeParams {
markdown: string;
}
interface BadgeConfig extends BadgeParams {
id: string;
}
export interface BadgeSpec {
/** The rendered fields, markdown code */
/** Badge id */
id: string;
/** Badge data */
badge: Badge;
/** The configuration data, with placeholders and all */
config: BadgeConfig;
/** The URL to the badge image */
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
/** The markdown code to use the badge */
markdown: string;
}
export interface BadgesApi {
@@ -65,16 +65,12 @@ export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => {
}, [badgesApi, entity, open]);
const content = (badges || []).map(
({
badge: { description, markdown },
config: { id },
context: { badge_url },
}) => (
({ badge: { description }, id, url, markdown }) => (
<div key={id}>
<DialogContentText>
{description}
{description || `${id} badge`}
<br />
<img alt={description} src={badge_url} />
<img alt={description || id} src={url} />
</DialogContentText>
<Typography component="div" className={classes.codeBlock}>
Copy the following snippet of markdown code for the badge: