badges: refactoring to use badge factories to create dynamic badges.
Signed-off-by: Andreas Stenius <andreas.stenius@svenskaspel.se>
This commit is contained in:
committed by
Fredrik Adelöw
parent
ad95bebc8f
commit
5a09c681d7
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user