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:
committed by
Fredrik Adelöw
parent
1cf23e05cb
commit
323ec6f7ca
+10
-10
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+18
-5
@@ -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}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -14,4 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { BadgesApi } from './BadgesApi';
|
||||
export { DefaultBadgeBuilder } from './DefaultBadgeBuilder';
|
||||
export * from './types';
|
||||
+21
@@ -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>;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user