diff --git a/packages/app/package.json b/packages/app/package.json index 967f893da4..df2ecdb277 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -9,6 +9,7 @@ "@backstage/core": "^0.7.2", "@backstage/integration-react": "^0.1.1", "@backstage/plugin-api-docs": "^0.4.9", + "@backstage/plugin-badges": "^0.1.1", "@backstage/plugin-catalog": "^0.5.0", "@backstage/plugin-catalog-import": "^0.5.0", "@backstage/plugin-catalog-react": "^0.1.2", diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 29d19785bf..13b0daf7e3 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -44,3 +44,4 @@ export { plugin as Search } from '@backstage/plugin-search'; export { plugin as Org } from '@backstage/plugin-org'; export { plugin as Kafka } from '@backstage/plugin-kafka'; export { todoPlugin } from '@backstage/plugin-todo'; +export { badgesPlugin } from '@backstage/plugin-badges'; diff --git a/packages/backend/package.json b/packages/backend/package.json index 98a69f6ff6..34f9f7c6de 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -33,6 +33,7 @@ "@backstage/config": "^0.1.4", "@backstage/plugin-app-backend": "^0.3.10", "@backstage/plugin-auth-backend": "^0.3.5", + "@backstage/plugin-badges-backend": "^0.1.1", "@backstage/plugin-catalog-backend": "^0.6.6", "@backstage/plugin-graphql-backend": "^0.1.6", "@backstage/plugin-kubernetes-backend": "^0.3.1", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 23739596e8..bdd4131e0e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -46,6 +46,7 @@ import techdocs from './plugins/techdocs'; import todo from './plugins/todo'; import graphql from './plugins/graphql'; import app from './plugins/app'; +import badges from './plugins/badges'; import { PluginEnvironment } from './types'; function makeCreateEnv(config: Config) { @@ -83,6 +84,7 @@ async function main() { const kafkaEnv = useHotMemoize(module, () => createEnv('kafka')); const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); const appEnv = useHotMemoize(module, () => createEnv('app')); + const badgesEnv = useHotMemoize(module, () => createEnv('badges')); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -95,6 +97,7 @@ async function main() { apiRouter.use('/kafka', await kafka(kafkaEnv)); apiRouter.use('/proxy', await proxy(proxyEnv)); apiRouter.use('/graphql', await graphql(graphqlEnv)); + apiRouter.use('/badges', await badges(badgesEnv)); apiRouter.use(notFoundHandler()); const service = createServiceBuilder(module) diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts new file mode 100644 index 0000000000..befca76542 --- /dev/null +++ b/packages/backend/src/plugins/badges.ts @@ -0,0 +1,32 @@ +/* + * 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 { + createRouter, + createDefaultBadgeFactories, +} from '@backstage/plugin-badges-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + config, + discovery, +}: PluginEnvironment) { + return await createRouter({ + config, + discovery, + badgeFactories: createDefaultBadgeFactories(), + }); +} diff --git a/plugins/badges-backend/.eslintrc.js b/plugins/badges-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/badges-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md new file mode 100644 index 0000000000..e64360656d --- /dev/null +++ b/plugins/badges-backend/README.md @@ -0,0 +1,98 @@ +# Badges Backend + +Backend plugin for serving badges to the `@backstage/plugin-badges` plugin. +Default implementation uses +[badge-maker](https://www.npmjs.com/package/badge-maker) for creating the +badges, in SVG. + +Currently, only entity badges are implemented. i.e. badges that may have entity +specific information in them, and as such, are served from an entity specific +endpoint. + +## Installation + +Install the `@backstage/plugin-badges-backend` package in your backend package, +and then integrate the plugin using the following default setup for +`src/plugins/badges.ts`: + +```ts +import { + createRouter, + createDefaultBadgeFactories, +} from '@backstage/plugin-badges-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + config, + discovery, +}: PluginEnvironment) { + return await createRouter({ + config, + discovery, + badgeFactories: createDefaultBadgeFactories(), + }); +} +``` + +The `createDefaultBadgeFactories()` returns an object with badge factories to +the badges-backend `createRouter()` to forward to the default badge builder. To +customize the available badges, provide a custom set of badge factories. See +further down for an example of a custom badge factories function. + +## Badge builder + +Badges are created by classes implementing the `BadgeBuilder` type. The default +badge builder uses badge factories to turn a `BadgeContext` into a `Badge` spec +for the `badge-maker` to create the SVG image. + +### Default badges + +A set of default badge factories are defined in +[badges.ts](https://github.com/backstage/backstage/tree/master/plugins/badges-backend/src/badges.ts) +as examples. + +Additional badges may be provided in your application by defining custom badge +factories, and provide them to the badge builder. + +### Custom badges + +To provide custom badges, create a badge factories function, and use that when +creating the badges backend router. + +```ts +import type { Badge, BadgeContext, BadgeFactories } from '@backstage/plugin-badges-backend'; +export const createMyCustomBadgeFactories = (): BadgeFactories => ({ + : { + createBadge: (context: BadgeContext): Badge | null => { + // ... + return { + label: 'my-badge', + message: 'custom stuff', + // ... + }; + }, + }, + + // optional: include the default badges + // ...createDefaultBadgeFactories(), +}); +``` + +## API + +The badges backend api exposes two main endpoints for entity badges. The +`/badges` prefix is arbitrary, and the default for the example backend. + +- `/badges/entity/:namespace/:kind/:name/badge-specs` List all defined badges + for a particular entity, in json format. See + [BadgeSpec](https://github.com/backstage/backstage/tree/master/plugins/badges/src/api/types.ts) + from the frontend plugin for a type declaration. + +- `/badges/entity/:namespace/:kind/:name/:badgeId` Get the entity badge as an + SVG image. If the `accept` request header prefers `application/json` the badge + spec as JSON will be returned instead of the image. + +## Links + +- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/badges) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json new file mode 100644 index 0000000000..75f681190d --- /dev/null +++ b/plugins/badges-backend/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/plugin-badges-backend", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/badges-backend" + }, + "keywords": [ + "backstage", + "badges" + ], + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.6.0", + "@backstage/catalog-client": "^0.3.6", + "@backstage/catalog-model": "^0.7.3", + "@backstage/config": "^0.1.3", + "@backstage/errors": "^0.1.1", + "@types/express": "^4.17.6", + "badge-maker": "^3.3.0", + "cors": "^2.8.5", + "cross-fetch": "^3.0.6", + "express": "^4.17.1", + "express-promise-router": "^3.0.3", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.6.3", + "@types/supertest": "^2.0.8", + "supertest": "^4.0.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/badges-backend/src/badges.ts b/plugins/badges-backend/src/badges.ts new file mode 100644 index 0000000000..2e2848a8af --- /dev/null +++ b/plugins/badges-backend/src/badges.ts @@ -0,0 +1,100 @@ +/* + * 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 { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; +import { Badge, BadgeContext, BadgeFactories } from './types'; + +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}`.toLowerCase(); +} + +export const createDefaultBadgeFactories = (): BadgeFactories => ({ + pingback: { + createBadge: (context: BadgeContext): Badge => { + if (!context.entity) { + throw new InputError('"pingback" badge only defined for entities'); + } + 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', + }; + }, + }, + + lifecycle: { + createBadge: (context: BadgeContext): Badge => { + if (!context.entity) { + throw new InputError('"lifecycle" badge only defined for entities'); + } + return { + description: 'Entity lifecycle badge', + kind: 'entity', + label: 'lifecycle', + link: entityUrl(context), + message: `${context.entity.spec?.lifecycle || 'unknown'}`, + style: 'flat-square', + }; + }, + }, + + owner: { + createBadge: (context: BadgeContext): Badge => { + if (!context.entity) { + throw new InputError('"owner" badge only defined for entities'); + } + return { + description: 'Entity owner badge', + kind: 'entity', + label: 'owner', + link: entityUrl(context), + message: `${context.entity.spec?.owner || 'unknown'}`, + style: 'flat-square', + }; + }, + }, + + docs: { + createBadge: (context: BadgeContext): Badge => { + if (!context.entity) { + throw new InputError('"docs" badge only defined for entities'); + } + return { + description: 'Entity docs badge', + kind: 'entity', + label: 'docs', + link: `${entityUrl(context)}/docs`, + message: context.entity.metadata.name, + style: 'flat-square', + }; + }, + }, +}); diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts new file mode 100644 index 0000000000..240bce56af --- /dev/null +++ b/plugins/badges-backend/src/index.ts @@ -0,0 +1,20 @@ +/* + * 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 * from './badges'; +export * from './lib'; +export * from './service/router'; +export * from './types'; diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts new file mode 100644 index 0000000000..a36587f241 --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { Config, ConfigReader } from '@backstage/config'; +import { DefaultBadgeBuilder } from './DefaultBadgeBuilder'; +import { BadgeBuilder, BadgeOptions } from './types'; +import { BadgeContext, BadgeFactories } from '../../types'; + +describe('DefaultBadgeBuilder', () => { + let builder: BadgeBuilder; + let config: Config; + let factories: BadgeFactories; + + const badge = { + description: 'a test badge', + label: 'test', + message: 'ok', + link: 'http://example.com/badgelink', + }; + + beforeAll(() => { + config = new ConfigReader({ + backend: { baseUrl: 'http://127.0.0.1' }, + }); + + factories = { + testbadge: { + createBadge: () => badge, + }, + }; + }); + + beforeEach(() => { + jest.resetAllMocks(); + builder = new DefaultBadgeBuilder(factories); + }); + + it('getBadges() returns all badge factory ids', async () => { + expect(await builder.getBadges()).toEqual([{ id: 'testbadge' }]); + }); + + describe('createBadge[Json|Svg]', () => { + const context: BadgeContext = { + badgeUrl: 'http://127.0.0.1/badge/url', + config, + }; + + it('badge spec', async () => { + const options: BadgeOptions = { + badgeInfo: { id: 'testbadge' }, + context, + }; + + const spec = await builder.createBadgeJson(options); + expect(spec).toEqual({ + badge, + id: 'testbadge', + url: context.badgeUrl, + markdown: `[![a test badge, test: ok](${context.badgeUrl} "a test badge")](${badge.link})`, + }); + }); + + it('badge image', async () => { + const options: BadgeOptions = { + badgeInfo: { id: 'testbadge' }, + context, + }; + + const img = await builder.createBadgeSvg(options); + expect(img).toEqual(expect.stringMatching(/^]*>.*<\/svg>$/)); + }); + + it('returns "unknown" badge for missing factory', async () => { + const options: BadgeOptions = { + badgeInfo: { id: 'other-id' }, + context, + }; + + const spec = await builder.createBadgeJson(options); + expect(spec).toEqual({ + badge: { + label: 'unknown badge', + message: 'other-id', + color: 'red', + }, + id: 'other-id', + url: context.badgeUrl, + markdown: `![unknown badge: other-id](${context.badgeUrl})`, + }); + }); + }); +}); diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts new file mode 100644 index 0000000000..42823ebd09 --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -0,0 +1,82 @@ +/* + * 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 { InputError } from '@backstage/errors'; +import { makeBadge, Format } from 'badge-maker'; +import { BadgeBuilder, BadgeInfo, BadgeOptions, BadgeSpec } from './types'; +import { Badge, BadgeFactories } from '../../types'; + +export class DefaultBadgeBuilder implements BadgeBuilder { + constructor(private readonly factories: BadgeFactories) {} + + public async getBadges(): Promise { + return Object.keys(this.factories).map(id => ({ id })); + } + + public async createBadgeJson(options: BadgeOptions): Promise { + const factory = this.factories[options.badgeInfo.id]; + const badge = factory + ? factory.createBadge(options.context) + : ({ + label: 'unknown badge', + message: options.badgeInfo.id, + color: 'red', + } as Badge); + + if (!badge) { + throw new InputError( + `The badge factory failed to produce a "${options.badgeInfo.id}" badge with the provided context`, + ); + } + + return { + badge, + id: options.badgeInfo.id, + url: options.context.badgeUrl, + markdown: this.getMarkdownCode(badge, options.context.badgeUrl), + }; + } + + public async createBadgeSvg(options: BadgeOptions): Promise { + const { badge } = await this.createBadgeJson(options); + try { + 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', + message: `${err}`, + color: 'red', + }); + } + } + + private getMarkdownCode(params: Badge, badgeUrl: string): string { + let altText = `${params.label}: ${params.message}`; + if (params.description && params.description !== params.label) { + altText = `${params.description}, ${altText}`; + } + const tooltip = params.description ? ` "${params.description}"` : ''; + const img = `![${altText}](${badgeUrl}${tooltip})`; + return params.link ? `[${img}](${params.link})` : img; + } +} diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/index.ts b/plugins/badges-backend/src/lib/BadgeBuilder/index.ts new file mode 100644 index 0000000000..4947db96e2 --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { DefaultBadgeBuilder } from './DefaultBadgeBuilder'; +export type { BadgeBuilder, BadgeInfo, BadgeOptions, BadgeSpec } from './types'; diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts new file mode 100644 index 0000000000..fcd1cd4c5b --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -0,0 +1,46 @@ +/* + * 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, BadgeContext } from '../../types'; + +export type BadgeInfo = { + id: string; +}; + +export type BadgeOptions = { + badgeInfo: BadgeInfo; + context: BadgeContext; +}; + +export type BadgeSpec = { + /** Badge id */ + id: string; + + /** Badge data */ + badge: Badge; + + /** The URL to the badge image */ + url: string; + + /** The markdown code to use the badge */ + markdown: string; +}; + +export type BadgeBuilder = { + getBadges(): Promise; + createBadgeJson(options: BadgeOptions): Promise; + createBadgeSvg(options: BadgeOptions): Promise; +}; diff --git a/plugins/badges-backend/src/lib/index.ts b/plugins/badges-backend/src/lib/index.ts new file mode 100644 index 0000000000..47cccec8f8 --- /dev/null +++ b/plugins/badges-backend/src/lib/index.ts @@ -0,0 +1,17 @@ +/* + * 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 * from './BadgeBuilder'; diff --git a/plugins/badges-backend/src/run.ts b/plugins/badges-backend/src/run.ts new file mode 100644 index 0000000000..a59d90d09a --- /dev/null +++ b/plugins/badges-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * 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 { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts new file mode 100644 index 0000000000..e8de5dfd42 --- /dev/null +++ b/plugins/badges-backend/src/service/router.test.ts @@ -0,0 +1,171 @@ +/* + * 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 express from 'express'; +import request from 'supertest'; +import { + PluginEndpointDiscovery, + SingleHostDiscovery, +} from '@backstage/backend-common'; +import { CatalogApi } from '@backstage/catalog-client'; +import type { Entity } from '@backstage/catalog-model'; +import { Config, ConfigReader } from '@backstage/config'; +import { createRouter } from './router'; +import { BadgeBuilder } from '../lib'; + +describe('createRouter', () => { + let app: express.Express; + let badgeBuilder: jest.Mocked; + let catalog: jest.Mocked; + let config: Config; + let discovery: PluginEndpointDiscovery; + + const entity: Entity = { + apiVersion: 'v1', + kind: 'service', + metadata: { + name: 'test', + }, + }; + + const badge = { + id: 'test-badge', + badge: { + label: 'test', + message: 'badge', + }, + url: '/...', + markdown: '[![...](...)]', + }; + + beforeAll(async () => { + badgeBuilder = { + getBadges: jest.fn(), + createBadgeJson: jest.fn(), + createBadgeSvg: jest.fn(), + }; + catalog = { + addLocation: jest.fn(), + getEntities: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + }; + + config = new ConfigReader({ + backend: { + baseUrl: 'http://127.0.0.1', + listen: { port: 7000 }, + }, + }); + discovery = SingleHostDiscovery.fromConfig(config); + + const router = await createRouter({ + badgeBuilder, + catalog, + config, + discovery, + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('works', async () => { + const router = await createRouter({ + badgeBuilder, + catalog, + config, + discovery, + }); + expect(router).toBeDefined(); + }); + + describe('GET /entity/:namespace/:kind/:name/badge-specs', () => { + it('returns all badge specs for entity', async () => { + catalog.getEntityByName.mockResolvedValueOnce(entity); + + badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]); + badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); + + const response = await request(app).get( + '/entity/default/service/test/badge-specs', + ); + + expect(response.status).toEqual(200); + expect(response.text).toEqual(JSON.stringify([badge], null, 2)); + + expect(catalog.getEntityByName).toHaveBeenCalledTimes(1); + expect(catalog.getEntityByName).toHaveBeenCalledWith({ + namespace: 'default', + kind: 'service', + name: 'test', + }); + + expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeJson).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeJson).toHaveBeenCalledWith({ + badgeInfo: { id: badge.id }, + context: { + badgeUrl: expect.stringMatching( + /http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/, + ), + config, + entity, + }, + }); + }); + }); + + describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => { + it('returns badge for entity', async () => { + catalog.getEntityByName.mockResolvedValueOnce(entity); + + const image = '...'; + badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image); + + const response = await request(app).get( + '/entity/default/service/test/badge/test-badge', + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(Buffer.from(image)); + + expect(catalog.getEntityByName).toHaveBeenCalledTimes(1); + expect(catalog.getEntityByName).toHaveBeenCalledWith({ + namespace: 'default', + kind: 'service', + name: 'test', + }); + + expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0); + expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({ + badgeInfo: { id: badge.id }, + context: { + badgeUrl: expect.stringMatching( + /http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/, + ), + config, + entity, + }, + }); + }); + }); +}); diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts new file mode 100644 index 0000000000..bfbb10fc94 --- /dev/null +++ b/plugins/badges-backend/src/service/router.ts @@ -0,0 +1,136 @@ +/* + * 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 express from 'express'; +import Router from 'express-promise-router'; +import { + errorHandler, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; +import { NotFoundError } from '@backstage/errors'; +import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; +import { BadgeContext, BadgeFactories } from '../types'; + +export interface RouterOptions { + badgeBuilder?: BadgeBuilder; + badgeFactories?: BadgeFactories; + catalog?: CatalogApi; + config: Config; + discovery: PluginEndpointDiscovery; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const catalog = + options.catalog || new CatalogClient({ discoveryApi: options.discovery }); + const badgeBuilder = + options.badgeBuilder || + new DefaultBadgeBuilder(options.badgeFactories || {}); + const router = Router(); + + router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => { + const { namespace, kind, name } = req.params; + const entity = await catalog.getEntityByName({ namespace, kind, name }); + if (!entity) { + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + ); + } + + const specs = []; + for (const badgeInfo of await badgeBuilder.getBadges()) { + const context: BadgeContext = { + badgeUrl: await getBadgeUrl( + namespace, + kind, + name, + badgeInfo.id, + options, + ), + config: options.config, + entity, + }; + + const badge = await badgeBuilder.createBadgeJson({ badgeInfo, context }); + if (badge) { + specs.push(badge); + } + } + + res.setHeader('Content-Type', 'application/json'); + res.status(200).send(JSON.stringify(specs, null, 2)); + }); + + router.get( + '/entity/:namespace/:kind/:name/badge/:badgeId', + async (req, res) => { + const { namespace, kind, name, badgeId } = req.params; + const entity = await catalog.getEntityByName({ namespace, kind, name }); + if (!entity) { + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + ); + } + + let format = + req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml'; + if (req.query.format === 'json') { + format = 'application/json'; + } + + const badgeOptions = { + badgeInfo: { id: badgeId }, + context: { + badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId, options), + config: options.config, + entity, + }, + }; + + let data: string; + if (format === 'application/json') { + data = JSON.stringify( + await badgeBuilder.createBadgeJson(badgeOptions), + null, + 2, + ); + } else { + data = await badgeBuilder.createBadgeSvg(badgeOptions); + } + + res.setHeader('Content-Type', format); + res.status(200).send(data); + }, + ); + + router.use(errorHandler()); + + return router; +} + +async function getBadgeUrl( + namespace: string, + kind: string, + name: string, + badgeId: string, + options: RouterOptions, +): Promise { + const baseUrl = await options.discovery.getExternalBaseUrl('badges'); + return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`; +} diff --git a/plugins/badges-backend/src/service/standaloneServer.ts b/plugins/badges-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..c210efa249 --- /dev/null +++ b/plugins/badges-backend/src/service/standaloneServer.ts @@ -0,0 +1,51 @@ +/* + * 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 { Server } from 'http'; +import { Logger } from 'winston'; +import { + createServiceBuilder, + loadBackendConfig, + SingleHostDiscovery, +} from '@backstage/backend-common'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'badges-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + const discovery = SingleHostDiscovery.fromConfig(config); + + logger.debug('Creating application...'); + + const router = await createRouter({ config, discovery }); + + const service = createServiceBuilder(module) + .enableCors({ origin: 'http://localhost:3000' }) + .addRouter('/badges', router); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} diff --git a/plugins/badges-backend/src/setupTests.ts b/plugins/badges-backend/src/setupTests.ts new file mode 100644 index 0000000000..4e230aca20 --- /dev/null +++ b/plugins/badges-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * 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 {}; diff --git a/plugins/badges-backend/src/types.ts b/plugins/badges-backend/src/types.ts new file mode 100644 index 0000000000..69fc275f3f --- /dev/null +++ b/plugins/badges-backend/src/types.ts @@ -0,0 +1,63 @@ +/* + * 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 { Config } from '@backstage/config'; +import { Entity } from '@backstage/catalog-model'; + +export const BADGE_STYLES = [ + 'plastic', + 'flat', + 'flat-square', + 'for-the-badge', + 'social', +] as const; +export type BadgeStyle = typeof BADGE_STYLES[number]; + +export interface Badge { + /** Badge message background color. */ + color?: string; + /** Badge description (tooltip text) */ + description?: string; + /** Kind of badge */ + 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; +} + +export interface BadgeContext { + badgeUrl: string; + config: Config; + entity?: Entity; // for entity badges +} + +export interface BadgeFactory { + createBadge(context: BadgeContext): Badge; +} + +export interface BadgeFactories { + [id: string]: BadgeFactory; +} diff --git a/plugins/badges/.eslintrc.js b/plugins/badges/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/badges/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/badges/README.md b/plugins/badges/README.md new file mode 100644 index 0000000000..bfc01ec63c --- /dev/null +++ b/plugins/badges/README.md @@ -0,0 +1,19 @@ +# @backstage/plugin-badges + +The badges plugin offers a set of badges that can be used outside of +your backstage deployment, showing information related to data from +the catalog, such as entity owner and lifecycle data for instance. + +The available badges are setup in the `badges-backend` plugin, see +link below for more details. + +## Entity badges + +To get markdown code for the entity badges, access the `Badges` context menu +(three dots in the upper right corner) of an entity page, which will popup a +badges dialog showing all available badges for that entity. + +## Links + +- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/badges-backend) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/badges/dev/index.tsx b/plugins/badges/dev/index.tsx new file mode 100644 index 0000000000..1616edacb4 --- /dev/null +++ b/plugins/badges/dev/index.tsx @@ -0,0 +1,19 @@ +/* + * 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 { createDevApp } from '@backstage/dev-utils'; +import { badgesPlugin } from '../src/plugin'; + +createDevApp().registerPlugin(badgesPlugin).render(); diff --git a/plugins/badges/package.json b/plugins/badges/package.json new file mode 100644 index 0000000000..cc55a0fed8 --- /dev/null +++ b/plugins/badges/package.json @@ -0,0 +1,51 @@ +{ + "name": "@backstage/plugin-badges", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.7.3", + "@backstage/core": "^0.7.2", + "@backstage/errors": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/theme": "^0.2.4", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.6.5", + "@backstage/dev-utils": "^0.1.13", + "@backstage/test-utils": "^0.1.9", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts new file mode 100644 index 0000000000..927a16a7eb --- /dev/null +++ b/plugins/badges/src/api/BadgesClient.ts @@ -0,0 +1,58 @@ +/* + * 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 { generatePath } from 'react-router'; +import { DiscoveryApi } from '@backstage/core'; +import { ResponseError } from '@backstage/errors'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { entityRoute } from '@backstage/plugin-catalog-react'; +import { BadgesApi, BadgeSpec } from './types'; + +export class BadgesClient implements BadgesApi { + private readonly discoveryApi: DiscoveryApi; + + constructor(options: { discoveryApi: DiscoveryApi }) { + this.discoveryApi = options.discoveryApi; + } + + public async getEntityBadgeSpecs(entity: Entity): Promise { + const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity); + const response = await fetch(entityBadgeSpecsUrl); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return await response.json(); + } + + private async getEntityBadgeSpecsUrl(entity: Entity): Promise { + const routeParams = this.getEntityRouteParams(entity); + const path = generatePath(entityRoute.path, routeParams); + return `${await this.discoveryApi.getBaseUrl( + 'badges', + )}/entity/${path}/badge-specs`; + } + + private getEntityRouteParams(entity: Entity) { + return { + kind: entity.kind.toLowerCase(), + namespace: + entity.metadata.namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + name: entity.metadata.name, + }; + } +} diff --git a/plugins/badges/src/api/index.ts b/plugins/badges/src/api/index.ts new file mode 100644 index 0000000000..1c6a381916 --- /dev/null +++ b/plugins/badges/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * 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 * from './BadgesClient'; +export * from './types'; diff --git a/plugins/badges/src/api/types.ts b/plugins/badges/src/api/types.ts new file mode 100644 index 0000000000..9765af2c93 --- /dev/null +++ b/plugins/badges/src/api/types.ts @@ -0,0 +1,59 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { createApiRef } from '@backstage/core'; + +export const badgesApiRef = createApiRef({ + id: 'plugin.badges.client', + description: 'Used to make requests to the badges backend', +}); + +export type BadgeStyle = + | 'plastic' + | 'flat' + | 'flat-square' + | 'for-the-badge' + | 'social'; + +interface Badge { + color?: string; + description?: string; + kind?: 'entity'; + label: string; + labelColor?: string; + link?: string; + message: string; + style?: BadgeStyle; +} + +export interface BadgeSpec { + /** Badge id */ + id: string; + + /** Badge data */ + badge: Badge; + + /** The URL to the badge image */ + url: string; + + /** The markdown code to use the badge */ + markdown: string; +} + +export interface BadgesApi { + getEntityBadgeSpecs(entity: Entity): Promise; +} diff --git a/plugins/badges/src/components/EntityBadgesDialog.test.tsx b/plugins/badges/src/components/EntityBadgesDialog.test.tsx new file mode 100644 index 0000000000..2491a3462c --- /dev/null +++ b/plugins/badges/src/components/EntityBadgesDialog.test.tsx @@ -0,0 +1,61 @@ +/* + * 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 React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { + ApiProvider, + ApiRegistry, + ErrorApi, + errorApiRef, +} from '@backstage/core'; +import { renderWithEffects } from '@backstage/test-utils'; +import { BadgesApi, badgesApiRef } from '../api'; +import { EntityBadgesDialog } from './EntityBadgesDialog'; + +describe('EntityBadgesDialog', () => { + it('should render', async () => { + const mockApi: jest.Mocked = { + getEntityBadgeSpecs: jest.fn().mockResolvedValue([ + { + id: 'testbadge', + badge: { + label: 'test', + message: 'badge', + }, + url: 'http://127.0.0.1/badges/entity/.../testbadge', + markdown: '![test: badge](http://127.0.0.1/catalog/...)', + }, + ]), + }; + const mockEntity = { metadata: { name: 'mock' } } as Entity; + + const rendered = await renderWithEffects( + + + , + ); + + await expect( + rendered.findByText('testbadge badge'), + ).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx new file mode 100644 index 0000000000..74a244e698 --- /dev/null +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -0,0 +1,102 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { + CodeSnippet, + Progress, + ResponseErrorPanel, + useApi, +} from '@backstage/core'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, + useMediaQuery, + useTheme, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { badgesApiRef } from '../api'; + +type Props = { + open: boolean; + onClose?: () => any; + entity: Entity; +}; + +const useStyles = makeStyles({ + codeBlock: { + '& code': { + whiteSpace: 'pre-wrap', + }, + }, +}); + +export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const badgesApi = useApi(badgesApiRef); + const classes = useStyles(); + + const { value: badges, loading, error } = useAsync(async () => { + if (open) { + return await badgesApi.getEntityBadgeSpecs(entity); + } + + return []; + }, [badgesApi, entity, open]); + + const content = (badges || []).map( + ({ badge: { description }, id, url, markdown }) => ( +
+ + {description || `${id} badge`} +
+ {description +
+ + Copy the following snippet of markdown code for the badge: + + +
+
+ ), + ); + + return ( + + Entity Badges + + + {loading && } + {error && } + {content} + + + + + + + ); +}; diff --git a/plugins/badges/src/index.ts b/plugins/badges/src/index.ts new file mode 100644 index 0000000000..4de5957426 --- /dev/null +++ b/plugins/badges/src/index.ts @@ -0,0 +1,17 @@ +/* + * 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 * from './plugin'; diff --git a/plugins/badges/src/plugin.test.ts b/plugins/badges/src/plugin.test.ts new file mode 100644 index 0000000000..eb5a1fc0ba --- /dev/null +++ b/plugins/badges/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * 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 { badgesPlugin } from './plugin'; + +describe('badges', () => { + it('should export plugin', () => { + expect(badgesPlugin).toBeDefined(); + }); +}); diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts new file mode 100644 index 0000000000..92932aa356 --- /dev/null +++ b/plugins/badges/src/plugin.ts @@ -0,0 +1,44 @@ +/* + * 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 { + createApiFactory, + createComponentExtension, + createPlugin, + discoveryApiRef, +} from '@backstage/core'; +import { badgesApiRef, BadgesClient } from './api'; + +export const badgesPlugin = createPlugin({ + id: 'badges', + apis: [ + createApiFactory({ + api: badgesApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new BadgesClient({ discoveryApi }), + }), + ], +}); + +export const EntityBadgesDialog = badgesPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/EntityBadgesDialog').then( + m => m.EntityBadgesDialog, + ), + }, + }), +); diff --git a/plugins/badges/src/setupTests.ts b/plugins/badges/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/badges/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * 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 '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 8c05c376f9..d2c5085035 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -35,6 +35,7 @@ "@backstage/core": "^0.7.2", "@backstage/integration": "^0.5.1", "@backstage/integration-react": "^0.1.1", + "@backstage/plugin-badges": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 82cf556281..b9af9b307e 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -35,10 +35,14 @@ const useStyles = makeStyles({ }); type Props = { + onShowBadgesDialog?: () => void; onUnregisterEntity: () => void; }; -export const EntityContextMenu = ({ onUnregisterEntity }: Props) => { +export const EntityContextMenu = ({ + onShowBadgesDialog, + onUnregisterEntity, +}: Props) => { const [anchorEl, setAnchorEl] = useState(); const classes = useStyles(); @@ -70,6 +74,16 @@ export const EntityContextMenu = ({ onUnregisterEntity }: Props) => { transformOrigin={{ vertical: 'top', horizontal: 'right' }} > + {onShowBadgesDialog && ( + { + onClose(); + onShowBadgesDialog(); + }} + > + Badges + + )} { onClose(); diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index 5cd66fca51..64a51f2bb8 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -40,6 +40,7 @@ import { useNavigate } from 'react-router'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; +import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { Tabbed } from './Tabbed'; const EntityPageTitle = ({ @@ -108,6 +109,7 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { entity!, ); + const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const navigate = useNavigate(); const cleanUpAfterRemoval = async () => { @@ -128,7 +130,10 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { {entity && ( <> - + setBadgesDialogOpen(true)} + onUnregisterEntity={showRemovalDialog} + /> )} @@ -159,6 +164,14 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { )} + {entity && ( + setBadgesDialogOpen(false)} + /> + )} +