badges-backend: refactoring and support returning json as well as svg image.

Signed-off-by: Andreas Stenius <git@astekk.se>
This commit is contained in:
Andreas Stenius
2021-02-22 14:09:46 +01:00
committed by Fredrik Adelöw
parent 1cf23e05cb
commit 323ec6f7ca
9 changed files with 206 additions and 105 deletions
+10 -10
View File
@@ -406,30 +406,30 @@ pagerduty:
badges:
pingback:
kind: 'entity'
description: 'Link back to $${app.title}'
target: '$${entity_url}'
label: 'View $${entity.metadata.name} in'
message: '$${app.title}'
description: 'Link back to _{app.title}'
target: '_{entity_url}'
label: 'View _{entity.metadata.name} in'
message: '_{app.title}'
lifecycle:
kind: 'entity'
description: 'Entity lifecycle badge'
target: '$${entity_url}'
target: '_{entity_url}'
label: 'Lifecycle'
message: '$${entity.spec.lifecycle}'
message: '_{entity.spec.lifecycle}'
owner:
kind: 'entity'
title: 'Resource Owner'
description: 'Entity owner badge'
target: '$${entity_url}'
target: '_{entity_url}'
label: 'Owner'
message: '$${entity.spec.owner}'
message: '_{entity.spec.owner}'
color: 'blue'
docs:
kind: 'entity'
target: '$${entity_url}/docs'
target: '_{entity_url}/docs'
label: 'Documentation'
message: '$${entity.metadata.name}'
message: '_{entity.metadata.name}'
color: 'navyblue'
+22 -2
View File
@@ -1,10 +1,30 @@
# Badges Backend
Simple plugin for serving badges.
Backend plugin for serving badges. Default implementation uses
[badge-maker](https://www.npmjs.com/package/badge-maker) for creating
the badges, in svg.
## Setup
N/A
Define which badges to offer in the backend api by declaring them in
the app-config, under a `badges` key. Example:
```yaml
badges:
docs:
kind: 'entity'
target: '_{entity_url}/docs'
label: 'Documentation'
message: '_{entity.metadata.name}'
color: 'navyblue'
lifecycle:
kind: 'entity'
description: 'Entity lifecycle badge'
target: '_{entity_url}'
label: 'Lifecycle'
message: '_{entity.spec.lifecycle}'
```
## Links
+18 -5
View File
@@ -20,7 +20,8 @@ export interface Config {
*
* 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 `$$`.
* javascript template literals, but with `_` instead of `$`,
* e.g. `_{context.variable.name}`.
*
*/
badges?: {
@@ -35,12 +36,13 @@ export interface Config {
* Available badge kinds:
*
* * `entity` The entity data is available as `entity` in the template.
* * `entity_url` The (frontend) URL to view the entity in Backstage.
*
* Default context for all badges:
*
* * `app.title` As configured or defaults to "Backstage".
*
* @visibility: frontend
* @visibility frontend
*/
kind?: 'entity';
@@ -51,14 +53,25 @@ export interface Config {
label: string;
/**
* The badge color. Default: `#36BAA2`.
* The badge message.
*/
message: string;
/**
* The message color. Default: `#36BAA2`.
*/
color?: string;
/**
* The badge message.
* The label color. Default: `gray`.
*/
message: string;
labelColor?: string;
/**
* Visual design of the badge. One of: 'plastic', 'flat', 'flat-square',
* 'for-the-badge' or 'social'.
*/
style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social';
};
};
}
@@ -1,70 +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.
*/
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,
private readonly config: { [id: string]: BadgeConfig },
) {}
public getBadge(badgeKind: string, badgeId: string, context: object) {
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',
});
}
const svg = makeBadge({
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}]`;
}
}
}
-1
View File
@@ -14,6 +14,5 @@
* limitations under the License.
*/
export * from './api';
export * from './service/router';
export * from './utils';
@@ -0,0 +1,87 @@
/*
* 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 { Logger } from 'winston';
import { makeBadge, ValidationError } from 'badge-maker';
import { BadgeBuilder, BadgeConfig, BadgeOptions } from './types';
import { interpolate } from '../../utils';
export class DefaultBadgeBuilder implements BadgeBuilder {
constructor(
private readonly logger: Logger,
private readonly config: { [id: string]: BadgeConfig },
) {}
public async getBadgeConfig(badgeId: string): BadgeConfig {
return (
this.config[badgeId] ||
this.config.default || {
label: 'Unknown badge ID',
message: badgeId,
color: 'red',
}
);
}
public async createBadge(options: BadgeOptions): string {
const { context, config: badge } = options;
const params = {
label: this.render(badge.label, context),
message: this.render(badge.message, context),
color: badge.color || '#36BAA2',
};
if (badge.labelColor) {
params.labelColor = badge.labelColor;
}
if (badge.style) {
params.style = badge.style;
}
switch (options.format) {
case 'json':
return JSON.stringify(
{
badge: params,
...options,
},
null,
2,
);
case 'svg':
try {
return makeBadge(params);
} catch (err) {
return makeBadge({
label: 'Invalid badge parameters',
message: `${err}`,
color: 'red',
});
}
default:
throw new TypeError(`unsupported badge format: ${options.format}`);
}
}
private render(template: string, context: object): string {
try {
return interpolate(template.replace('_{', '${'), context);
} catch (err) {
return `${err} [${template}]`;
}
}
}
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export { BadgesApi } from './BadgesApi';
export { DefaultBadgeBuilder } from './DefaultBadgeBuilder';
export * from './types';
@@ -13,3 +13,24 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface BadgeConfig {
kind?: 'entity';
label: string;
message: string;
color?: string;
labelColor?: string;
style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social';
links?: [string, string];
}
export type BadgeOptions = {
context: object;
config: BadgeConfig;
format: 'svg' | 'json';
};
export type BadgeBuilder = {
createBadge(options: BadgeOptions): Promise<string>;
getBadgeConfig(badgeId: string): Promise<BadgeConfig>;
};
+46 -16
View File
@@ -23,10 +23,10 @@ import {
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { BadgesApi } from '../api';
import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder';
export interface RouterOptions {
badgesApi?: BadgesApi;
badgeBuilder: BadgeBuilder;
logger: Logger;
config: Config;
discovery: PluginEndpointDiscovery;
@@ -36,27 +36,53 @@ export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = Router();
const logger = options.logger.child({ plugin: 'badges' });
const badgesConfig = options.config.getOptional('badges') ?? {};
const title = options.config.getString('app.title') || 'Backstage';
const badgesApi = options.badgesApi || new BadgesApi(logger, badgesConfig);
const catalogUrl = `${options.config.getString('app.baseUrl')}/catalog`;
const badgesConfig = options.config.getOptional('badges') ?? {};
const badgeBuilder =
options.badgeBuilder || new DefaultBadgeBuilder(logger, badgesConfig);
router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => {
const entity = await getEntity(logger, options.discovery, req.params);
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(logger, options.discovery, entityUri);
if (!entity) {
res.status(400).send(`Unknown entity`);
return;
}
const { badgeId } = req.params;
const badge = badgesApi.getBadge('entity', badgeId, {
app: { title },
entity,
let format =
req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml';
if (req.query.format === 'json') {
format = 'application/json';
}
if (req.query.style) {
badge.style = req.query.style;
}
const data = await badgeBuilder.createBadge({
config: badge,
context: {
app: { title },
entity,
entity_url: `${catalogUrl}/${entityUri}`,
},
format: format === 'application/json' ? 'json' : 'svg',
});
res.setHeader('Content-Type', 'image/svg+xml');
res.status(200).send(badge);
res.setHeader('Content-Type', format);
res.status(200).send(data);
});
router.use(errorHandler());
@@ -64,18 +90,22 @@ export async function createRouter(
return router;
}
async function getEntity(logger, discovery, params) {
const catalogUrl = await discovery.getBaseUrl('catalog');
function getEntityUri(params) {
const { kind, namespace, name } = params;
return `${kind}/${namespace}/${name}`;
}
async function getEntity(logger, discovery, entityUri) {
const catalogUrl = await discovery.getBaseUrl('catalog');
try {
const entity = (await (
await fetch(`${catalogUrl}/entities/by-name/${kind}/${namespace}/${name}`)
await fetch(`${catalogUrl}/entities/by-name/${entityUri}`)
).json()) as Entity;
return entity;
} catch (err) {
const msg = `Unable to get entity ${kind}/${namespace}/${name}, error ${err}`;
const msg = `Unable to get entity ${entityUri}, error ${err}`;
logger.info(msg);
return null;
}