From 81846585db00722b1723514b1665efecd0e99224 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Wed, 17 Feb 2021 13:59:28 +0100 Subject: [PATCH 01/24] badges: initial work for a powered by badge. #4457 Signed-off-by: Andreas Stenius --- plugins/badges-backend/.eslintrc.js | 3 ++ plugins/badges-backend/README.md | 12 +++++ plugins/badges-backend/config.d.ts | 21 ++++++++ plugins/badges-backend/package.json | 53 +++++++++++++++++++ plugins/badges-backend/src/api/BadgesApi.ts | 32 +++++++++++ plugins/badges-backend/src/api/index.ts | 17 ++++++ plugins/badges-backend/src/api/types.ts | 15 ++++++ plugins/badges-backend/src/index.ts | 18 +++++++ plugins/badges-backend/src/run.ts | 33 ++++++++++++ .../badges-backend/src/service/router.test.ts | 25 +++++++++ plugins/badges-backend/src/service/router.ts | 53 +++++++++++++++++++ .../src/service/standaloneServer.ts | 49 +++++++++++++++++ plugins/badges-backend/src/setupTests.ts | 17 ++++++ plugins/badges/.eslintrc.js | 3 ++ plugins/badges/README.md | 10 ++++ plugins/badges/dev/index.tsx | 28 ++++++++++ plugins/badges/dev/page.tsx | 38 +++++++++++++ plugins/badges/dev/plugin.ts | 39 ++++++++++++++ plugins/badges/package.json | 48 +++++++++++++++++ .../src/components/EntityBadgesCard.tsx | 48 +++++++++++++++++ plugins/badges/src/index.ts | 16 ++++++ plugins/badges/src/plugin.test.ts | 22 ++++++++ plugins/badges/src/plugin.ts | 29 ++++++++++ plugins/badges/src/routes.ts | 21 ++++++++ plugins/badges/src/setupTests.ts | 17 ++++++ 25 files changed, 667 insertions(+) create mode 100644 plugins/badges-backend/.eslintrc.js create mode 100644 plugins/badges-backend/README.md create mode 100644 plugins/badges-backend/config.d.ts create mode 100644 plugins/badges-backend/package.json create mode 100644 plugins/badges-backend/src/api/BadgesApi.ts create mode 100644 plugins/badges-backend/src/api/index.ts create mode 100644 plugins/badges-backend/src/api/types.ts create mode 100644 plugins/badges-backend/src/index.ts create mode 100644 plugins/badges-backend/src/run.ts create mode 100644 plugins/badges-backend/src/service/router.test.ts create mode 100644 plugins/badges-backend/src/service/router.ts create mode 100644 plugins/badges-backend/src/service/standaloneServer.ts create mode 100644 plugins/badges-backend/src/setupTests.ts create mode 100644 plugins/badges/.eslintrc.js create mode 100644 plugins/badges/README.md create mode 100644 plugins/badges/dev/index.tsx create mode 100644 plugins/badges/dev/page.tsx create mode 100644 plugins/badges/dev/plugin.ts create mode 100644 plugins/badges/package.json create mode 100644 plugins/badges/src/components/EntityBadgesCard.tsx create mode 100644 plugins/badges/src/index.ts create mode 100644 plugins/badges/src/plugin.test.ts create mode 100644 plugins/badges/src/plugin.ts create mode 100644 plugins/badges/src/routes.ts create mode 100644 plugins/badges/src/setupTests.ts 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..fa3ccbc4eb --- /dev/null +++ b/plugins/badges-backend/README.md @@ -0,0 +1,12 @@ +# Badges Backend + +Simple plugin for serving badges. + +## Setup + +N/A + +## 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/config.d.ts b/plugins/badges-backend/config.d.ts new file mode 100644 index 0000000000..7470a9c58f --- /dev/null +++ b/plugins/badges-backend/config.d.ts @@ -0,0 +1,21 @@ +/* + * 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 interface Config { + /** Configuration options for the badges-backend plugin */ + /* badges?: { + };*/ +} diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json new file mode 100644 index 0000000000..dc78d37696 --- /dev/null +++ b/plugins/badges-backend/package.json @@ -0,0 +1,53 @@ +{ + "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.5.2", + "@backstage/config": "^0.1.2", + "@types/express": "^4.17.6", + "badge-maker": "^3.3.0", + "cors": "^2.8.5", + "express": "^4.17.1", + "express-promise-router": "^3.0.3", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.6.0", + "@types/supertest": "^2.0.8", + "supertest": "^4.0.2" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/badges-backend/src/api/BadgesApi.ts b/plugins/badges-backend/src/api/BadgesApi.ts new file mode 100644 index 0000000000..2ce6bb5588 --- /dev/null +++ b/plugins/badges-backend/src/api/BadgesApi.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 { Logger } from 'winston'; +import { makeBadge, ValidationError } from 'badge-maker'; +import {} from './types'; + +export class BadgesApi { + constructor(private readonly logger: Logger) {} + + public getPoweredByBadge() { + const svg = makeBadge({ + label: 'Powered By', + message: 'Backstage', + color: '#36BAA2', + }); + return svg; + } +} diff --git a/plugins/badges-backend/src/api/index.ts b/plugins/badges-backend/src/api/index.ts new file mode 100644 index 0000000000..0a6328bd5c --- /dev/null +++ b/plugins/badges-backend/src/api/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 { BadgesApi } from './BadgesApi'; diff --git a/plugins/badges-backend/src/api/types.ts b/plugins/badges-backend/src/api/types.ts new file mode 100644 index 0000000000..863d6e76e1 --- /dev/null +++ b/plugins/badges-backend/src/api/types.ts @@ -0,0 +1,15 @@ +/* + * 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. + */ diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts new file mode 100644 index 0000000000..8c88c101af --- /dev/null +++ b/plugins/badges-backend/src/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 './api'; +export * from './service/router'; 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..94a0b099c0 --- /dev/null +++ b/plugins/badges-backend/src/service/router.test.ts @@ -0,0 +1,25 @@ +/* + * 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 { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import express from 'express'; +import request from 'supertest'; +import { BadgesApi } from '../api'; +import { createRouter } from './router'; +import {} from '../api/types'; + +describe('createRouter', () => {}); diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts new file mode 100644 index 0000000000..1cc367a0c6 --- /dev/null +++ b/plugins/badges-backend/src/service/router.ts @@ -0,0 +1,53 @@ +/* + * 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 { Logger } from 'winston'; +import { errorHandler } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { BadgesApi } from '../api'; + +export interface RouterOptions { + badgesApi?: BadgesApi; + logger: Logger; + config: Config; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const router = Router(); + + const logger = options.logger.child({ plugin: 'badges' }); + // const config = options.config.getConfig('badges'); + + const badgesApi = options.badgesApi || new BadgesApi(logger); + + // router.use(express.json()); + + router.get('/:kind/:namespace/:entityname', async (req, res) => { + const { kind, namespace, entityname } = req.params; + const badge = badgesApi.getPoweredByBadge(); + + res.setHeader('Content-Type', 'image/svg+xml'); + res.status(200).send(badge); + }); + + router.use(errorHandler()); + + return router; +} diff --git a/plugins/badges-backend/src/service/standaloneServer.ts b/plugins/badges-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..5108437726 --- /dev/null +++ b/plugins/badges-backend/src/service/standaloneServer.ts @@ -0,0 +1,49 @@ +/* + * 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, +} 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 }); + + logger.debug('Creating application...'); + + const router = await createRouter({ logger, config }); + + 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/.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..5e89e1294f --- /dev/null +++ b/plugins/badges/README.md @@ -0,0 +1,10 @@ +# badges + +Welcome to the badges plugin! + +_This plugin was created through the Backstage CLI_ + +## 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..8a289a51dc --- /dev/null +++ b/plugins/badges/dev/index.tsx @@ -0,0 +1,28 @@ +/* + * 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 { createDevApp } from '@backstage/dev-utils'; +import { badgesPlugin, EntityBadgesCard } from '../src/plugin'; +import { badgesDevPlugin, BadgesDevPage } from './plugin'; + +createDevApp() + .registerPlugin(badgesPlugin) + .registerPlugin(badgesDevPlugin) + .addPage({ + element: , + title: 'Dev Page', + }) + .render(); diff --git a/plugins/badges/dev/page.tsx b/plugins/badges/dev/page.tsx new file mode 100644 index 0000000000..b6ff91538a --- /dev/null +++ b/plugins/badges/dev/page.tsx @@ -0,0 +1,38 @@ +/* + * 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 { Typography, Grid } from '@material-ui/core'; +import { Content, Header, Page, ContentHeader } from '@backstage/core'; +import { EntityBadgesCard } from '../src'; + +export default () => { + return ( + +
+ + + + + + + + + + ); +}; diff --git a/plugins/badges/dev/plugin.ts b/plugins/badges/dev/plugin.ts new file mode 100644 index 0000000000..7f1574f999 --- /dev/null +++ b/plugins/badges/dev/plugin.ts @@ -0,0 +1,39 @@ +/* + * 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 { + createPlugin, + createRoutableExtension, + createRouteRef, +} from '@backstage/core'; +import BadgesDevPageComponent from './page'; + +const devRouteRef = createRouteRef({ + title: 'Badges (Dev Page)', +}); + +export const badgesDevPlugin = createPlugin({ + id: 'badges-dev', + routes: { + root: devRouteRef, + }, +}); + +export const BadgesDevPage = badgesDevPlugin.provide( + createRoutableExtension({ + component: () => import('./page').then(m => m.default), + mountPoint: devRouteRef, + }), +); diff --git a/plugins/badges/package.json b/plugins/badges/package.json new file mode 100644 index 0000000000..ebc4c4305e --- /dev/null +++ b/plugins/badges/package.json @@ -0,0 +1,48 @@ +{ + "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/core": "^0.6.1", + "@backstage/plugin-catalog-react": "^0.0.3", + "@backstage/theme": "^0.2.3", + "@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-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.7", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/badges/src/components/EntityBadgesCard.tsx b/plugins/badges/src/components/EntityBadgesCard.tsx new file mode 100644 index 0000000000..bf11493655 --- /dev/null +++ b/plugins/badges/src/components/EntityBadgesCard.tsx @@ -0,0 +1,48 @@ +/* + * 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 { Typography } from '@material-ui/core'; +import { CodeSnippet, InfoCard } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; + +export const EntityBadgesCard = () => { + const { entity } = useEntity(); + + // TODO: extract kind, namespace, entity name from entity data... + const { kind, namespace, entityname } = { + kind: 'Component', + namespace: 'default', + entityname: 'demo', + }; + + // TODO: get url for backstage and the badges api + const backstage_ui = 'http://localhost:3000'; + const backstage_api = 'http://localhost:7000'; + const badge = `${backstage_api}/badges/${kind}/${namespace}/${entityname}`; + + const target = backstage_ui; + const markdown_code = `[![Powered by Backstage](${badge})](${target})`; + return ( + + + Paste the following snippet in your README.md or other + markdown file, to get a powered by backstage badge: + + Powered by Backstage badge + + + ); +}; diff --git a/plugins/badges/src/index.ts b/plugins/badges/src/index.ts new file mode 100644 index 0000000000..63129bcbc2 --- /dev/null +++ b/plugins/badges/src/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { badgesPlugin, EntityBadgesCard } 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..5829dd50eb --- /dev/null +++ b/plugins/badges/src/plugin.ts @@ -0,0 +1,29 @@ +/* + * 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 { createPlugin, createComponentExtension } from '@backstage/core'; + +export const badgesPlugin = createPlugin({ + id: 'badges', +}); + +export const EntityBadgesCard = badgesPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/EntityBadgesCard').then(m => m.EntityBadgesCard), + }, + }), +); diff --git a/plugins/badges/src/routes.ts b/plugins/badges/src/routes.ts new file mode 100644 index 0000000000..ed6fa151f5 --- /dev/null +++ b/plugins/badges/src/routes.ts @@ -0,0 +1,21 @@ +/* + * 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 { createRouteRef } from '@backstage/core'; + +export const badgeRouteRef = createRouteRef({ + title: 'Powered By Badge', + path: 'badges/:kind/:namespace/:entityname', +}); 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'; From 300c291a098d6a2b5a696c9a6dd33cea0608f648 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Thu, 18 Feb 2021 09:43:28 +0100 Subject: [PATCH 02/24] badges: tweak badge URLs. Signed-off-by: Andreas Stenius --- plugins/badges-backend/src/service/router.ts | 4 ++-- plugins/badges/src/components/EntityBadgesCard.tsx | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 1cc367a0c6..b171977cf6 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -39,8 +39,8 @@ export async function createRouter( // router.use(express.json()); - router.get('/:kind/:namespace/:entityname', async (req, res) => { - const { kind, namespace, entityname } = req.params; + router.get('/entity/:namespace/:kind/:name', async (req, res) => { + const { namespace, kind, name } = req.params; const badge = badgesApi.getPoweredByBadge(); res.setHeader('Content-Type', 'image/svg+xml'); diff --git a/plugins/badges/src/components/EntityBadgesCard.tsx b/plugins/badges/src/components/EntityBadgesCard.tsx index bf11493655..334575e9db 100644 --- a/plugins/badges/src/components/EntityBadgesCard.tsx +++ b/plugins/badges/src/components/EntityBadgesCard.tsx @@ -22,19 +22,20 @@ export const EntityBadgesCard = () => { const { entity } = useEntity(); // TODO: extract kind, namespace, entity name from entity data... - const { kind, namespace, entityname } = { + const { kind, namespace, name } = { kind: 'Component', namespace: 'default', - entityname: 'demo', + name: 'demo', }; // TODO: get url for backstage and the badges api const backstage_ui = 'http://localhost:3000'; const backstage_api = 'http://localhost:7000'; - const badge = `${backstage_api}/badges/${kind}/${namespace}/${entityname}`; - const target = backstage_ui; + const badge = `${backstage_api}/badges/entity/${namespace}/${kind}/${name}`.toLowerCase(); + const target = `${backstage_ui}/catalog/${namespace}/${kind}/${name}`.toLowerCase(); const markdown_code = `[![Powered by Backstage](${badge})](${target})`; + return ( From 61078ba14d4dfba3d592792e321f17b5af8ca45a Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Thu, 18 Feb 2021 18:25:12 +0100 Subject: [PATCH 03/24] badges: introduce badges field on entity about card. Signed-off-by: Andreas Stenius --- packages/app/package.json | 1 + packages/app/src/plugins.ts | 1 + packages/backend/package.json | 1 + packages/backend/src/index.ts | 3 + packages/backend/src/plugins/badges.ts | 25 ++++++++ plugins/badges-backend/src/service/router.ts | 2 - plugins/badges/package.json | 2 + plugins/badges/src/BadgesClientApi.ts | 62 ++++++++++++++++++ .../badges/src/components/BadgesDrawer.tsx | 63 +++++++++++++++++++ .../src/components/EntityBadgesCard.tsx | 31 ++++----- .../src/components/EntityBadgesField.tsx | 52 +++++++++++++++ plugins/badges/src/index.ts | 3 +- plugins/badges/src/plugin.ts | 26 +++++++- plugins/catalog/package.json | 1 + .../src/components/AboutCard/AboutContent.tsx | 4 ++ 15 files changed, 255 insertions(+), 22 deletions(-) create mode 100644 packages/backend/src/plugins/badges.ts create mode 100644 plugins/badges/src/BadgesClientApi.ts create mode 100644 plugins/badges/src/components/BadgesDrawer.tsx create mode 100644 plugins/badges/src/components/EntityBadgesField.tsx 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..961c94e1ce --- /dev/null +++ b/packages/backend/src/plugins/badges.ts @@ -0,0 +1,25 @@ +/* + * 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 } from '@backstage/plugin-badges-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); +} diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index b171977cf6..26c56f7f86 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -37,8 +37,6 @@ export async function createRouter( const badgesApi = options.badgesApi || new BadgesApi(logger); - // router.use(express.json()); - router.get('/entity/:namespace/:kind/:name', async (req, res) => { const { namespace, kind, name } = req.params; const badge = badgesApi.getPoweredByBadge(); diff --git a/plugins/badges/package.json b/plugins/badges/package.json index ebc4c4305e..65e09e2524 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -20,6 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/catalog-model": "^0.7.1", "@backstage/core": "^0.6.1", "@backstage/plugin-catalog-react": "^0.0.3", "@backstage/theme": "^0.2.3", @@ -28,6 +29,7 @@ "@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": { diff --git a/plugins/badges/src/BadgesClientApi.ts b/plugins/badges/src/BadgesClientApi.ts new file mode 100644 index 0000000000..dfb1205784 --- /dev/null +++ b/plugins/badges/src/BadgesClientApi.ts @@ -0,0 +1,62 @@ +/* + * 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 { createApiRef, ConfigApi, DiscoveryApi } from '@backstage/core'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { entityRoute } from '@backstage/plugin-catalog-react'; + +export class BadgesClientApi { + private readonly configApi: ConfigApi; + private readonly discoveryApi: DiscoveryApi; + + constructor(options: { configApi: ConfigApi; discoveryApi: DiscoveryApi }) { + this.configApi = options.configApi; + this.discoveryApi = options.discoveryApi; + } + + async getEntityPoweredByMarkdownCode(entity: Entity): string { + const badge = await this.getEntityPoweredByBadgeURL(entity); + const target = this.getEntityLink(entity); + return `[![Powered by Backstage](${badge})](${target})`; + } + + async getEntityPoweredByBadgeURL(entity: Entity): string { + const routeParams = this.getEntityRouteParams(entity); + const path = generatePath(entityRoute.path, routeParams); + return `${await this.discoveryApi.getBaseUrl('badges')}/entity/${path}`; + } + + private getEntityLink(entity: Entity): string { + const routeParams = this.getEntityRouteParams(entity); + const path = generatePath(entityRoute.path, routeParams); + return `${this.configApi.getString('app.baseUrl')}/catalog/${path}`; + } + + private getEntityRouteParams(entity: Entity) { + return { + kind: entity.kind.toLowerCase(), + namespace: + entity.metadata.namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + name: entity.metadata.name, + }; + } +} + +export const badgesClientApiRef = createApiRef({ + id: 'plugin.badges.client', + description: 'Used by the badges plugin to create badge URLs', +}); diff --git a/plugins/badges/src/components/BadgesDrawer.tsx b/plugins/badges/src/components/BadgesDrawer.tsx new file mode 100644 index 0000000000..10ae0f861f --- /dev/null +++ b/plugins/badges/src/components/BadgesDrawer.tsx @@ -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 React, { useState } from 'react'; +import { Button, Drawer, Grid, Typography } from '@material-ui/core'; +import { CodeSnippet, Content, InfoCard, useApi } from '@backstage/core'; + +const BadgeCard = ({ title, badgeUrl, markdownCode }) => ( + + + + Paste the following snippet in your README.md or other + markdown file for this badge: + + Badge preview + + + +); + +const BadgesDrawerContent = ({ badges, toggleDrawer }) => ( + + + {badges.map((badge, idx) => ( + + ))} + + + + + +); + +export const BadgesDrawer = ({ badges }) => { + const [isOpen, toggleDrawer] = useState(false); + + return ( + <> + + toggleDrawer(false)}> + + + + ); +}; diff --git a/plugins/badges/src/components/EntityBadgesCard.tsx b/plugins/badges/src/components/EntityBadgesCard.tsx index 334575e9db..5fc0f47556 100644 --- a/plugins/badges/src/components/EntityBadgesCard.tsx +++ b/plugins/badges/src/components/EntityBadgesCard.tsx @@ -14,27 +14,22 @@ * limitations under the License. */ import React from 'react'; +import { useAsync } from 'react-use'; import { Typography } from '@material-ui/core'; -import { CodeSnippet, InfoCard } from '@backstage/core'; +import { CodeSnippet, InfoCard, useApi } from '@backstage/core'; +import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; +import { badgesClientApiRef } from '../BadgesClientApi'; export const EntityBadgesCard = () => { + const badgesClientApi = useApi(badgesClientApiRef); const { entity } = useEntity(); - - // TODO: extract kind, namespace, entity name from entity data... - const { kind, namespace, name } = { - kind: 'Component', - namespace: 'default', - name: 'demo', - }; - - // TODO: get url for backstage and the badges api - const backstage_ui = 'http://localhost:3000'; - const backstage_api = 'http://localhost:7000'; - - const badge = `${backstage_api}/badges/entity/${namespace}/${kind}/${name}`.toLowerCase(); - const target = `${backstage_ui}/catalog/${namespace}/${kind}/${name}`.toLowerCase(); - const markdown_code = `[![Powered by Backstage](${badge})](${target})`; + const { value, loading, error } = useAsync(async () => { + return { + badge: await badgesClientApi.getEntityPoweredByBadgeURL(entity), + markdown: await badgesClientApi.getEntityPoweredByMarkdownCode(entity), + }; + }, [badgesClientApi, entity]); return ( @@ -42,8 +37,8 @@ export const EntityBadgesCard = () => { Paste the following snippet in your README.md or other markdown file, to get a powered by backstage badge: - Powered by Backstage badge - + {value && Powered by Backstage badge} + {value && } ); }; diff --git a/plugins/badges/src/components/EntityBadgesField.tsx b/plugins/badges/src/components/EntityBadgesField.tsx new file mode 100644 index 0000000000..586f9d366d --- /dev/null +++ b/plugins/badges/src/components/EntityBadgesField.tsx @@ -0,0 +1,52 @@ +/* + * 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 { useAsync } from 'react-use'; +import { Progress, useApi, WarningPanel } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { badgesClientApiRef } from '../BadgesClientApi'; +import { BadgesDrawer } from './BadgesDrawer'; + +export const EntityBadgesField = () => { + const badgesClientApi = useApi(badgesClientApiRef); + const { entity } = useEntity(); + const { value, loading, error } = useAsync(async () => { + return { + badge: await badgesClientApi.getEntityPoweredByBadgeURL(entity), + markdown: await badgesClientApi.getEntityPoweredByMarkdownCode(entity), + }; + }, [badgesClientApi, entity]); + + const badges = []; + + if (value) { + badges.push({ + title: 'Powered by Backstage', + badgeUrl: value.badge, + markdownCode: value.markdown, + }); + } + + return ( +
+ {loading && } + {error && ( + + )} + {badges.length > 0 && } +
+ ); +}; diff --git a/plugins/badges/src/index.ts b/plugins/badges/src/index.ts index 63129bcbc2..4de5957426 100644 --- a/plugins/badges/src/index.ts +++ b/plugins/badges/src/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { badgesPlugin, EntityBadgesCard } from './plugin'; + +export * from './plugin'; diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index 5829dd50eb..7acaaeaeef 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -13,12 +13,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createPlugin, createComponentExtension } from '@backstage/core'; +import { + createApiFactory, + createComponentExtension, + createPlugin, + configApiRef, + discoveryApiRef, +} from '@backstage/core'; +import { badgesClientApiRef, BadgesClientApi } from './BadgesClientApi'; export const badgesPlugin = createPlugin({ id: 'badges', + apis: [ + createApiFactory({ + api: badgesClientApiRef, + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => + new BadgesClientApi({ configApi, discoveryApi }), + }), + ], }); +export const EntityBadgesField = badgesPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/EntityBadgesField').then(m => m.EntityBadgesField), + }, + }), +); + export const EntityBadgesCard = badgesPlugin.provide( createComponentExtension({ component: { 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/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx index 695595285b..d799668433 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -25,6 +25,7 @@ import { } from '@backstage/plugin-catalog-react'; import { Chip, Grid, makeStyles, Typography } from '@material-ui/core'; import React from 'react'; +import { EntityBadgesField } from '@backstage/plugin-badges'; import { AboutField } from './AboutField'; const useStyles = makeStyles({ @@ -127,6 +128,9 @@ export const AboutContent = ({ entity }: Props) => { ))} + + + ); }; From 798002ee030b7112ae665a428e9932f72deb018b Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Fri, 19 Feb 2021 17:33:32 +0100 Subject: [PATCH 04/24] badges-backend: refactor to use badge definitions in config. #4457 Signed-off-by: Andreas Stenius --- app-config.yaml | 5 ++ packages/backend/src/plugins/badges.ts | 3 +- plugins/badges-backend/config.d.ts | 47 ++++++++++++++++-- plugins/badges-backend/package.json | 1 + plugins/badges-backend/src/api/BadgesApi.ts | 48 +++++++++++++++++-- plugins/badges-backend/src/service/router.ts | 43 ++++++++++++++--- .../src/service/standaloneServer.ts | 4 +- plugins/badges-backend/src/utils.ts | 25 ++++++++++ 8 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 plugins/badges-backend/src/utils.ts diff --git a/app-config.yaml b/app-config.yaml index 465c7b0049..8bf6868353 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -401,3 +401,8 @@ homepage: timezone: 'Asia/Tokyo' pagerduty: eventsBaseUrl: 'https://events.pagerduty.com/v2' +badges: + pingback: + kind: 'entity' + label: 'View $${entity.metadata.name} in' + message: '$${app.title}' diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts index 961c94e1ce..132a90e38e 100644 --- a/packages/backend/src/plugins/badges.ts +++ b/packages/backend/src/plugins/badges.ts @@ -20,6 +20,7 @@ import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, config, + discovery, }: PluginEnvironment) { - return await createRouter({ logger, config }); + return await createRouter({ logger, config, discovery }); } diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts index 7470a9c58f..2fa3296104 100644 --- a/plugins/badges-backend/config.d.ts +++ b/plugins/badges-backend/config.d.ts @@ -15,7 +15,48 @@ */ export interface Config { - /** Configuration options for the badges-backend plugin */ - /* badges?: { - };*/ + /** + * Define all badges. + * + * 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 `$$`. + * + */ + badges?: { + [badgeId: string]: { + /** + * (Optional) Badge kind. + * + * Restrict badge usage to a certain kind only. + * Useful when using templating for label and/or message if they + * use context data only available for a certain kind of badge. + * + * Available badge kinds: + * + * * `entity` The entity data is available as `entity` in the template. + * + * Default context for all badges: + * + * * `app.title` As configured or defaults to "Backstage". + * + */ + kind?: 'entity'; + + /** + * The badge label. + */ + label: string; + + /** + * The badge color. Default: `#36BAA2`. + */ + color?: string; + + /** + * The badge message. + */ + message: string; + }; + }; } diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index dc78d37696..3d77acbe98 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -35,6 +35,7 @@ "@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", diff --git a/plugins/badges-backend/src/api/BadgesApi.ts b/plugins/badges-backend/src/api/BadgesApi.ts index 2ce6bb5588..5dd68e852d 100644 --- a/plugins/badges-backend/src/api/BadgesApi.ts +++ b/plugins/badges-backend/src/api/BadgesApi.ts @@ -17,16 +17,54 @@ 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) {} + constructor( + private readonly logger: Logger, + private readonly config: { [id: string]: BadgeConfig }, + ) {} + + public getBadge(badgeKind: string, badgeId: string, context: any) { + 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', + }); + } - public getPoweredByBadge() { const svg = makeBadge({ - label: 'Powered By', - message: 'Backstage', - color: '#36BAA2', + 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}]`; + } + } } diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 26c56f7f86..cc470a33b8 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -15,9 +15,13 @@ */ import express from 'express'; +import fetch from 'cross-fetch'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { errorHandler } from '@backstage/backend-common'; +import { + errorHandler, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { BadgesApi } from '../api'; @@ -25,6 +29,7 @@ export interface RouterOptions { badgesApi?: BadgesApi; logger: Logger; config: Config; + discovery: PluginEndpointDiscovery; } export async function createRouter( @@ -33,13 +38,22 @@ export async function createRouter( const router = Router(); const logger = options.logger.child({ plugin: 'badges' }); - // const config = options.config.getConfig('badges'); + const badgesConfig = options.config.getOptional('badges') ?? {}; + const title = options.config.getString('app.title') || 'Backstage'; + const badgesApi = options.badgesApi || new BadgesApi(logger, badgesConfig); - const badgesApi = options.badgesApi || new BadgesApi(logger); + router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => { + const entity = await getEntity(logger, options.discovery, req.params); + if (!entity) { + res.status(400).send(`Unknown entity`); + return; + } - router.get('/entity/:namespace/:kind/:name', async (req, res) => { - const { namespace, kind, name } = req.params; - const badge = badgesApi.getPoweredByBadge(); + const { badgeId } = req.params; + const badge = badgesApi.getBadge('entity', badgeId, { + app: { title }, + entity, + }); res.setHeader('Content-Type', 'image/svg+xml'); res.status(200).send(badge); @@ -49,3 +63,20 @@ export async function createRouter( return router; } + +async function getEntity(logger, discovery, params) { + const catalogUrl = await discovery.getBaseUrl('catalog'); + const { kind, namespace, name } = params; + + try { + const entity = (await ( + await fetch(`${catalogUrl}/entities/by-name/${kind}/${namespace}/${name}`) + ).json()) as Entity; + + return entity; + } catch (err) { + const msg = `Unable to get entity ${kind}/${namespace}/${name}, error ${err}`; + logger.info(msg); + return null; + } +} diff --git a/plugins/badges-backend/src/service/standaloneServer.ts b/plugins/badges-backend/src/service/standaloneServer.ts index 5108437726..d358edcbdc 100644 --- a/plugins/badges-backend/src/service/standaloneServer.ts +++ b/plugins/badges-backend/src/service/standaloneServer.ts @@ -19,6 +19,7 @@ import { Logger } from 'winston'; import { createServiceBuilder, loadBackendConfig, + SingleHostDiscovery, } from '@backstage/backend-common'; import { createRouter } from './router'; @@ -33,10 +34,11 @@ export async function startStandaloneServer( ): 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({ logger, config }); + const router = await createRouter({ logger, config, discovery }); const service = createServiceBuilder(module) .enableCors({ origin: 'http://localhost:3000' }) diff --git a/plugins/badges-backend/src/utils.ts b/plugins/badges-backend/src/utils.ts new file mode 100644 index 0000000000..e9090905e3 --- /dev/null +++ b/plugins/badges-backend/src/utils.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * adapted from https://stackoverflow.com/a/41015840/444060 + */ +export function interpolate(template, params) { + const names = Object.keys(params); + const vals = Object.values(params); + // eslint-disable-next-line no-new-func + return new Function(...names, `return \`${template}\`;`)(...vals); +} From b99231c0bce8739a0626888a30b2bd9d54dd9e32 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Fri, 19 Feb 2021 21:45:47 +0100 Subject: [PATCH 05/24] badges: move to context menu. support for multiple badges from config. #4457. Signed-off-by: Andreas Stenius --- app-config.yaml | 18 ++++ plugins/badges-backend/config.d.ts | 2 + plugins/badges-backend/src/api/BadgesApi.ts | 2 +- plugins/badges-backend/src/index.ts | 1 + plugins/badges/config.d.ts | 47 ++++++++ plugins/badges/package.json | 6 +- plugins/badges/src/BadgesClientApi.ts | 69 ++++++++++-- .../src/components/EntityBadgesDialog.tsx | 101 ++++++++++++++++++ plugins/badges/src/plugin.ts | 11 ++ plugins/badges/src/routes.ts | 21 ---- .../EntityContextMenu/EntityContextMenu.tsx | 16 ++- .../EntityPageLayout/EntityPageLayout.tsx | 13 ++- 12 files changed, 275 insertions(+), 32 deletions(-) create mode 100644 plugins/badges/config.d.ts create mode 100644 plugins/badges/src/components/EntityBadgesDialog.tsx delete mode 100644 plugins/badges/src/routes.ts diff --git a/app-config.yaml b/app-config.yaml index 8bf6868353..5efebaab91 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -401,8 +401,26 @@ homepage: timezone: 'Asia/Tokyo' pagerduty: eventsBaseUrl: 'https://events.pagerduty.com/v2' + +# sample badges badges: pingback: kind: 'entity' + description: 'Link back to $${app.title}' + target: '$${window.location.href}' label: 'View $${entity.metadata.name} in' message: '$${app.title}' + + lifecycle: + kind: 'entity' + description: 'Entity lifecycle badge' + target: '$${window.location.href}' + label: 'Lifecycle' + message: '$${entity.spec.lifecycle}' + + owner: + kind: 'entity' + description: 'Entity owner badge' + target: '$${window.location.href}' + label: 'Owner' + message: '$${entity.spec.owner}' diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts index 2fa3296104..298c72be6e 100644 --- a/plugins/badges-backend/config.d.ts +++ b/plugins/badges-backend/config.d.ts @@ -40,11 +40,13 @@ export interface Config { * * * `app.title` As configured or defaults to "Backstage". * + * @visibility: frontend */ kind?: 'entity'; /** * The badge label. + * */ label: string; diff --git a/plugins/badges-backend/src/api/BadgesApi.ts b/plugins/badges-backend/src/api/BadgesApi.ts index 5dd68e852d..17613b4284 100644 --- a/plugins/badges-backend/src/api/BadgesApi.ts +++ b/plugins/badges-backend/src/api/BadgesApi.ts @@ -32,7 +32,7 @@ export class BadgesApi { private readonly config: { [id: string]: BadgeConfig }, ) {} - public getBadge(badgeKind: string, badgeId: string, context: any) { + public getBadge(badgeKind: string, badgeId: string, context: object) { const badge = this.config[badgeId] || this.config.default; if (!badge) { diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts index 8c88c101af..d82b8a9c43 100644 --- a/plugins/badges-backend/src/index.ts +++ b/plugins/badges-backend/src/index.ts @@ -16,3 +16,4 @@ export * from './api'; export * from './service/router'; +export * from './utils'; diff --git a/plugins/badges/config.d.ts b/plugins/badges/config.d.ts new file mode 100644 index 0000000000..371444ab77 --- /dev/null +++ b/plugins/badges/config.d.ts @@ -0,0 +1,47 @@ +/* + * 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 interface Config { + // defined with doc in badges-backend + badges?: { + [badgeId: string]: { + /** + * Badge title, used as tooltip text in the markdown code. + * + * @visibility frontend + */ + title?: string; + + /** + * Badge description, used as alt text in the markdown code. + * Defaults to badge id. + * + * @visibility frontend + */ + description?: string; + + /** + * Badge target URL, turns badge into a link in the markdown code. + * + * For `entity` badges, there is a `entity_url` in the context + * that could be appropriate to use here. + * + * @visibility frontend + */ + target?: string; + }; + }; +} diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 65e09e2524..39a220a534 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -45,6 +45,8 @@ "msw": "^0.21.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/badges/src/BadgesClientApi.ts b/plugins/badges/src/BadgesClientApi.ts index dfb1205784..14f4adebc8 100644 --- a/plugins/badges/src/BadgesClientApi.ts +++ b/plugins/badges/src/BadgesClientApi.ts @@ -19,6 +19,15 @@ import { createApiRef, ConfigApi, DiscoveryApi } from '@backstage/core'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { entityRoute } from '@backstage/plugin-catalog-react'; +// TODO: place interpolate code someplace re-usable... +// import { interpolate } from '@backstage/plugin-badges-backend'; +function interpolate(template, params) { + const names = Object.keys(params); + const vals = Object.values(params); + // eslint-disable-next-line no-new-func + return new Function(...names, `return \`${template}\`;`)(...vals); +} + export class BadgesClientApi { private readonly configApi: ConfigApi; private readonly discoveryApi: DiscoveryApi; @@ -28,16 +37,64 @@ export class BadgesClientApi { this.discoveryApi = options.discoveryApi; } - async getEntityPoweredByMarkdownCode(entity: Entity): string { - const badge = await this.getEntityPoweredByBadgeURL(entity); - const target = this.getEntityLink(entity); - return `[![Powered by Backstage](${badge})](${target})`; + async getDefaultContext(entity: Entity) { + return { + app: { + title: this.configApi.getString('app.title'), + }, + entity, + entity_url: entity && (await this.getEntityLink(entity)), + }; } - async getEntityPoweredByBadgeURL(entity: Entity): string { + async getDefinedBadges(badgeKind: string, context: object) { + const badges = []; + const badgesConfig = this.configApi.getOptional('badges') ?? {}; + + for (const [badgeId, badge] of Object.entries(badgesConfig)) { + if (!badgeKind || !badge.kind || badgeKind === badge.kind) { + badges.push(await this.renderBadgeInfo(badgeId, badge, context)); + } + } + + return badges; + } + + private async renderBadgeInfo(id, badge, context) { + const info = { + id, + url: context.entity && (await this.getEntityBadgeURL(id, context.entity)), + title: badge.title && this.render(badge.title, context), + description: this.render(badge.description || id, context), + target: badge.target && this.render(badge.target, context), + }; + info.markdown = this.getBadgeMarkdownCode(info); + return info; + } + + private async getEntityBadgeURL(badgeId: string, entity: Entity): string { const routeParams = this.getEntityRouteParams(entity); const path = generatePath(entityRoute.path, routeParams); - return `${await this.discoveryApi.getBaseUrl('badges')}/entity/${path}`; + return `${await this.discoveryApi.getBaseUrl( + 'badges', + )}/entity/${path}/${badgeId}`; + } + + private getBadgeMarkdownCode(badge: object): string { + const title = badge.title && ` "${badge.title}"`; + const img = `![${badge.description}](${badge.url}${title})`; + if (!badge.target) { + return img; + } + return `[${img}](${badge.target})`; + } + + private render(template, context) { + try { + return interpolate(template.replace('$$', '$'), context); + } catch (err) { + return `${err} [${template}]`; + } } private getEntityLink(entity: Entity): string { diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx new file mode 100644 index 0000000000..8b32bb8d4e --- /dev/null +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -0,0 +1,101 @@ +/* + * 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, 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 Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { badgesClientApiRef } from '../BadgesClientApi'; + +type Props = { + open: boolean; + onClose: () => any; + entity: Entity; +}; + +const useStyles = makeStyles(theme => ({ + codeBlock: { + '& code': { + whiteSpace: 'pre-wrap', + }, + }, +})); + +export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const badgesClientApi = useApi(badgesClientApiRef); + const classes = useStyles(); + + const { value: badges, loading, error } = useAsync(async () => { + const context = await badgesClientApi.getDefaultContext(entity); + return await badgesClientApi.getDefinedBadges('entity', context); + }); + + const content = (badges || []).map( + ({ id, title, description, url, target, markdown }) => ( +
+ + {title || description} +
+ {description} +
+ + Copy the following snippet of markdown code for the badge: + + +
+
+ ), + ); + + return ( + + Entity Badges + + + {loading ? : null} + + {error ? ( + + {error.toString()} + + ) : null} + + {content} + + + + + + + ); +}; diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index 7acaaeaeef..e4831158bc 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -51,3 +51,14 @@ export const EntityBadgesCard = badgesPlugin.provide( }, }), ); + +export const EntityBadgesDialog = badgesPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/EntityBadgesDialog').then( + m => m.EntityBadgesDialog, + ), + }, + }), +); diff --git a/plugins/badges/src/routes.ts b/plugins/badges/src/routes.ts deleted file mode 100644 index ed6fa151f5..0000000000 --- a/plugins/badges/src/routes.ts +++ /dev/null @@ -1,21 +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 { createRouteRef } from '@backstage/core'; - -export const badgeRouteRef = createRouteRef({ - title: 'Powered By Badge', - path: 'badges/:kind/:namespace/:entityname', -}); diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 82cf556281..a8da917fd8 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..663825b409 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,9 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { {entity && ( <> - + setBadgesDialogOpen(true)} + onUnregisterEntity={showRemovalDialog} /> )}
@@ -159,6 +163,13 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { )} + {entity && ( + setBadgesDialogOpen(false)} + /> + )} Date: Fri, 19 Feb 2021 22:22:05 +0100 Subject: [PATCH 06/24] badges: fix markdown title bug. cleanup about card. Signed-off-by: Andreas Stenius --- app-config.yaml | 15 ++++++++++++--- plugins/badges/src/BadgesClientApi.ts | 2 +- .../src/components/AboutCard/AboutContent.tsx | 4 ---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 5efebaab91..7a4fc0a929 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -407,20 +407,29 @@ badges: pingback: kind: 'entity' description: 'Link back to $${app.title}' - target: '$${window.location.href}' + target: '$${entity_url}' label: 'View $${entity.metadata.name} in' message: '$${app.title}' lifecycle: kind: 'entity' description: 'Entity lifecycle badge' - target: '$${window.location.href}' + target: '$${entity_url}' label: 'Lifecycle' message: '$${entity.spec.lifecycle}' owner: kind: 'entity' + title: 'Resource Owner' description: 'Entity owner badge' - target: '$${window.location.href}' + target: '$${entity_url}' label: 'Owner' message: '$${entity.spec.owner}' + color: 'blue' + + docs: + kind: 'entity' + target: '$${entity_url}/docs' + label: 'Documentation' + message: '$${entity.metadata.name}' + color: 'navyblue' diff --git a/plugins/badges/src/BadgesClientApi.ts b/plugins/badges/src/BadgesClientApi.ts index 14f4adebc8..79749e4933 100644 --- a/plugins/badges/src/BadgesClientApi.ts +++ b/plugins/badges/src/BadgesClientApi.ts @@ -81,7 +81,7 @@ export class BadgesClientApi { } private getBadgeMarkdownCode(badge: object): string { - const title = badge.title && ` "${badge.title}"`; + const title = badge.title ? ` "${badge.title}"` : ''; const img = `![${badge.description}](${badge.url}${title})`; if (!badge.target) { return img; diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx index d799668433..695595285b 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -25,7 +25,6 @@ import { } from '@backstage/plugin-catalog-react'; import { Chip, Grid, makeStyles, Typography } from '@material-ui/core'; import React from 'react'; -import { EntityBadgesField } from '@backstage/plugin-badges'; import { AboutField } from './AboutField'; const useStyles = makeStyles({ @@ -128,9 +127,6 @@ export const AboutContent = ({ entity }: Props) => { ))} - - - ); }; From 323ec6f7ca033581b643a0178ef7ea3865f5f1fe Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Mon, 22 Feb 2021 14:09:46 +0100 Subject: [PATCH 07/24] badges-backend: refactoring and support returning json as well as svg image. Signed-off-by: Andreas Stenius --- app-config.yaml | 20 ++--- plugins/badges-backend/README.md | 24 ++++- plugins/badges-backend/config.d.ts | 23 +++-- plugins/badges-backend/src/api/BadgesApi.ts | 70 --------------- plugins/badges-backend/src/index.ts | 1 - .../lib/BadgeBuilder/DefaultBadgeBuilder.ts | 87 +++++++++++++++++++ .../src/{api => lib/BadgeBuilder}/index.ts | 3 +- .../src/{api => lib/BadgeBuilder}/types.ts | 21 +++++ plugins/badges-backend/src/service/router.ts | 62 +++++++++---- 9 files changed, 206 insertions(+), 105 deletions(-) delete mode 100644 plugins/badges-backend/src/api/BadgesApi.ts create mode 100644 plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts rename plugins/badges-backend/src/{api => lib/BadgeBuilder}/index.ts (87%) rename plugins/badges-backend/src/{api => lib/BadgeBuilder}/types.ts (55%) diff --git a/app-config.yaml b/app-config.yaml index 7a4fc0a929..d4aa0694c8 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -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' diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index fa3ccbc4eb..cbd2dcf0c7 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -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 diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts index 298c72be6e..6ec56daf23 100644 --- a/plugins/badges-backend/config.d.ts +++ b/plugins/badges-backend/config.d.ts @@ -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'; }; }; } diff --git a/plugins/badges-backend/src/api/BadgesApi.ts b/plugins/badges-backend/src/api/BadgesApi.ts deleted file mode 100644 index 17613b4284..0000000000 --- a/plugins/badges-backend/src/api/BadgesApi.ts +++ /dev/null @@ -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}]`; - } - } -} diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts index d82b8a9c43..01917c0c80 100644 --- a/plugins/badges-backend/src/index.ts +++ b/plugins/badges-backend/src/index.ts @@ -14,6 +14,5 @@ * limitations under the License. */ -export * from './api'; export * from './service/router'; export * from './utils'; 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..38a06ed0cd --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -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}]`; + } + } +} diff --git a/plugins/badges-backend/src/api/index.ts b/plugins/badges-backend/src/lib/BadgeBuilder/index.ts similarity index 87% rename from plugins/badges-backend/src/api/index.ts rename to plugins/badges-backend/src/lib/BadgeBuilder/index.ts index 0a6328bd5c..20f18b8852 100644 --- a/plugins/badges-backend/src/api/index.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export { BadgesApi } from './BadgesApi'; +export { DefaultBadgeBuilder } from './DefaultBadgeBuilder'; +export * from './types'; diff --git a/plugins/badges-backend/src/api/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts similarity index 55% rename from plugins/badges-backend/src/api/types.ts rename to plugins/badges-backend/src/lib/BadgeBuilder/types.ts index 863d6e76e1..f58e35ec1d 100644 --- a/plugins/badges-backend/src/api/types.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -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; + getBadgeConfig(badgeId: string): Promise; +}; diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index cc470a33b8..d0013d8318 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -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 { 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; } From 8bf248799d59a309aad35f6eed2fa08c66621da2 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Mon, 22 Feb 2021 16:23:53 +0100 Subject: [PATCH 08/24] badges: refactoring frontend plugin. Signed-off-by: Andreas Stenius --- app-config.yaml | 8 +- plugins/badges-backend/config.d.ts | 25 ++++ .../lib/BadgeBuilder/DefaultBadgeBuilder.ts | 9 ++ .../src/lib/BadgeBuilder/types.ts | 4 +- plugins/badges/config.d.ts | 22 +--- plugins/badges/dev/index.tsx | 12 +- plugins/badges/dev/page.tsx | 38 ------ plugins/badges/src/BadgesClientApi.ts | 119 ------------------ plugins/badges/src/api/BadgesClient.ts | 86 +++++++++++++ .../{dev/plugin.ts => src/api/index.ts} | 25 +--- plugins/badges/src/api/types.ts | 56 +++++++++ .../badges/src/components/BadgesDrawer.tsx | 63 ---------- .../src/components/EntityBadgesCard.tsx | 44 ------- .../src/components/EntityBadgesDialog.tsx | 43 +++---- .../src/components/EntityBadgesField.tsx | 52 -------- plugins/badges/src/plugin.ts | 24 +--- 16 files changed, 213 insertions(+), 417 deletions(-) delete mode 100644 plugins/badges/dev/page.tsx delete mode 100644 plugins/badges/src/BadgesClientApi.ts create mode 100644 plugins/badges/src/api/BadgesClient.ts rename plugins/badges/{dev/plugin.ts => src/api/index.ts} (54%) create mode 100644 plugins/badges/src/api/types.ts delete mode 100644 plugins/badges/src/components/BadgesDrawer.tsx delete mode 100644 plugins/badges/src/components/EntityBadgesCard.tsx delete mode 100644 plugins/badges/src/components/EntityBadgesField.tsx diff --git a/app-config.yaml b/app-config.yaml index d4aa0694c8..9896b2feec 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -407,14 +407,14 @@ badges: pingback: kind: 'entity' description: 'Link back to _{app.title}' - target: '_{entity_url}' + link: '_{entity_url}' label: 'View _{entity.metadata.name} in' message: '_{app.title}' lifecycle: kind: 'entity' description: 'Entity lifecycle badge' - target: '_{entity_url}' + link: '_{entity_url}' label: 'Lifecycle' message: '_{entity.spec.lifecycle}' @@ -422,14 +422,14 @@ badges: kind: 'entity' title: 'Resource Owner' description: 'Entity owner badge' - target: '_{entity_url}' + link: '_{entity_url}' label: 'Owner' message: '_{entity.spec.owner}' color: 'blue' docs: kind: 'entity' - target: '_{entity_url}/docs' + link: '_{entity_url}/docs' label: 'Documentation' message: '_{entity.metadata.name}' color: 'navyblue' diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts index 6ec56daf23..b13f46e1fa 100644 --- a/plugins/badges-backend/config.d.ts +++ b/plugins/badges-backend/config.d.ts @@ -72,6 +72,31 @@ export interface Config { * 'for-the-badge' or 'social'. */ style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; + + /** + * Badge title, used as tooltip text in the markdown code. + * + * @visibility frontend + */ + title?: string; + + /** + * Badge description, used as alt text in the markdown code. + * Defaults to badge id. + * + * @visibility frontend + */ + description?: string; + + /** + * Badge link URL, turns badge into a link in the markdown code. + * + * For `entity` badges, there is a `entity_url` in the context + * that could be appropriate to use here. + * + * @visibility frontend + */ + link?: string; }; }; } diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index 38a06ed0cd..9b136dcf22 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -54,6 +54,15 @@ export class DefaultBadgeBuilder implements BadgeBuilder { switch (options.format) { case 'json': + if (badge.title) { + params.title = this.render(badge.title, context); + } + if (badge.description) { + params.description = this.render(badge.description, context); + } + if (badge.link) { + params.link = this.render(badge.link, context); + } return JSON.stringify( { badge: params, diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts index f58e35ec1d..ae898d645d 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -21,7 +21,9 @@ export interface BadgeConfig { color?: string; labelColor?: string; style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; - links?: [string, string]; + title?: string; + description?: string; + link?: string; } export type BadgeOptions = { diff --git a/plugins/badges/config.d.ts b/plugins/badges/config.d.ts index 371444ab77..000eaa0b9b 100644 --- a/plugins/badges/config.d.ts +++ b/plugins/badges/config.d.ts @@ -19,29 +19,9 @@ export interface Config { badges?: { [badgeId: string]: { /** - * Badge title, used as tooltip text in the markdown code. - * * @visibility frontend */ - title?: string; - - /** - * Badge description, used as alt text in the markdown code. - * Defaults to badge id. - * - * @visibility frontend - */ - description?: string; - - /** - * Badge target URL, turns badge into a link in the markdown code. - * - * For `entity` badges, there is a `entity_url` in the context - * that could be appropriate to use here. - * - * @visibility frontend - */ - target?: string; + kind?: 'entity'; }; }; } diff --git a/plugins/badges/dev/index.tsx b/plugins/badges/dev/index.tsx index 8a289a51dc..1f545a777e 100644 --- a/plugins/badges/dev/index.tsx +++ b/plugins/badges/dev/index.tsx @@ -15,14 +15,6 @@ */ import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { badgesPlugin, EntityBadgesCard } from '../src/plugin'; -import { badgesDevPlugin, BadgesDevPage } from './plugin'; +import { badgesPlugin } from '../src/plugin'; -createDevApp() - .registerPlugin(badgesPlugin) - .registerPlugin(badgesDevPlugin) - .addPage({ - element: , - title: 'Dev Page', - }) - .render(); +createDevApp().registerPlugin(badgesPlugin).render(); diff --git a/plugins/badges/dev/page.tsx b/plugins/badges/dev/page.tsx deleted file mode 100644 index b6ff91538a..0000000000 --- a/plugins/badges/dev/page.tsx +++ /dev/null @@ -1,38 +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 React from 'react'; -import { Typography, Grid } from '@material-ui/core'; -import { Content, Header, Page, ContentHeader } from '@backstage/core'; -import { EntityBadgesCard } from '../src'; - -export default () => { - return ( - -
- - - - - - - - - - ); -}; diff --git a/plugins/badges/src/BadgesClientApi.ts b/plugins/badges/src/BadgesClientApi.ts deleted file mode 100644 index 79749e4933..0000000000 --- a/plugins/badges/src/BadgesClientApi.ts +++ /dev/null @@ -1,119 +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 { generatePath } from 'react-router'; -import { createApiRef, ConfigApi, DiscoveryApi } from '@backstage/core'; -import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; -import { entityRoute } from '@backstage/plugin-catalog-react'; - -// TODO: place interpolate code someplace re-usable... -// import { interpolate } from '@backstage/plugin-badges-backend'; -function interpolate(template, params) { - const names = Object.keys(params); - const vals = Object.values(params); - // eslint-disable-next-line no-new-func - return new Function(...names, `return \`${template}\`;`)(...vals); -} - -export class BadgesClientApi { - private readonly configApi: ConfigApi; - private readonly discoveryApi: DiscoveryApi; - - constructor(options: { configApi: ConfigApi; discoveryApi: DiscoveryApi }) { - this.configApi = options.configApi; - this.discoveryApi = options.discoveryApi; - } - - async getDefaultContext(entity: Entity) { - return { - app: { - title: this.configApi.getString('app.title'), - }, - entity, - entity_url: entity && (await this.getEntityLink(entity)), - }; - } - - async getDefinedBadges(badgeKind: string, context: object) { - const badges = []; - const badgesConfig = this.configApi.getOptional('badges') ?? {}; - - for (const [badgeId, badge] of Object.entries(badgesConfig)) { - if (!badgeKind || !badge.kind || badgeKind === badge.kind) { - badges.push(await this.renderBadgeInfo(badgeId, badge, context)); - } - } - - return badges; - } - - private async renderBadgeInfo(id, badge, context) { - const info = { - id, - url: context.entity && (await this.getEntityBadgeURL(id, context.entity)), - title: badge.title && this.render(badge.title, context), - description: this.render(badge.description || id, context), - target: badge.target && this.render(badge.target, context), - }; - info.markdown = this.getBadgeMarkdownCode(info); - return info; - } - - private async getEntityBadgeURL(badgeId: string, entity: Entity): string { - const routeParams = this.getEntityRouteParams(entity); - const path = generatePath(entityRoute.path, routeParams); - return `${await this.discoveryApi.getBaseUrl( - 'badges', - )}/entity/${path}/${badgeId}`; - } - - private getBadgeMarkdownCode(badge: object): string { - const title = badge.title ? ` "${badge.title}"` : ''; - const img = `![${badge.description}](${badge.url}${title})`; - if (!badge.target) { - return img; - } - return `[${img}](${badge.target})`; - } - - private render(template, context) { - try { - return interpolate(template.replace('$$', '$'), context); - } catch (err) { - return `${err} [${template}]`; - } - } - - private getEntityLink(entity: Entity): string { - const routeParams = this.getEntityRouteParams(entity); - const path = generatePath(entityRoute.path, routeParams); - return `${this.configApi.getString('app.baseUrl')}/catalog/${path}`; - } - - private getEntityRouteParams(entity: Entity) { - return { - kind: entity.kind.toLowerCase(), - namespace: - entity.metadata.namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, - name: entity.metadata.name, - }; - } -} - -export const badgesClientApiRef = createApiRef({ - id: 'plugin.badges.client', - description: 'Used by the badges plugin to create badge URLs', -}); diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts new file mode 100644 index 0000000000..9c0387b58e --- /dev/null +++ b/plugins/badges/src/api/BadgesClient.ts @@ -0,0 +1,86 @@ +/* + * 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 { ConfigApi, DiscoveryApi } from '@backstage/core'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { entityRoute } from '@backstage/plugin-catalog-react'; +import { BadgesApi, BadgeConfig, BadgeSpec } from './types'; + +export class BadgesClient implements BadgesApi { + private readonly configApi: ConfigApi; + private readonly discoveryApi: DiscoveryApi; + + constructor(options: { configApi: ConfigApi; discoveryApi: DiscoveryApi }) { + this.configApi = options.configApi; + this.discoveryApi = options.discoveryApi; + } + + public async getDefinedEntityBadges(entity: Entity): Badge[] { + const badges = []; + const badgesConfig = this.configApi.getOptional('badges') ?? {}; + const entityBadgeUri = await this.getEntityBadgeUri(entity); + + for (const [badgeId, badge] of Object.entries(badgesConfig)) { + if (!badge.kind || badge.kind === 'entity') { + const info = await this.getBadgeInfo(entityBadgeUri, badgeId); + if (info) { + badges.push(info); + } + } + } + + return badges; + } + + private async getBadgeInfo(entityBadgeUri: string, badgeId: string): Badge { + const badgeUrl = `${entityBadgeUri}/${badgeId}`; + const spec = (await ( + await fetch(`${badgeUrl}?format=json`) + ).json()) as BadgeSpec; + + return { + id: badgeId, + markdown: this.getBadgeMarkdownCode(badgeUrl, spec.badge), + spec, + url: badgeUrl, + }; + } + + private async getEntityBadgeUri(entity: Entity): string { + const routeParams = this.getEntityRouteParams(entity); + const path = generatePath(entityRoute.path, routeParams); + return `${await this.discoveryApi.getBaseUrl('badges')}/entity/${path}`; + } + + private getEntityRouteParams(entity: Entity) { + return { + kind: entity.kind.toLowerCase(), + namespace: + entity.metadata.namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + name: entity.metadata.name, + }; + } + + private getBadgeMarkdownCode(badgeUrl: string, badge: BadgeConfig): string { + const title = badge.title ? ` "${badge.title}"` : ''; + const img = `![${badge.description || 'badge'}](${badgeUrl}${title})`; + if (!badge.link) { + return img; + } + return `[${img}](${badge.link})`; + } +} diff --git a/plugins/badges/dev/plugin.ts b/plugins/badges/src/api/index.ts similarity index 54% rename from plugins/badges/dev/plugin.ts rename to plugins/badges/src/api/index.ts index 7f1574f999..1c6a381916 100644 --- a/plugins/badges/dev/plugin.ts +++ b/plugins/badges/src/api/index.ts @@ -13,27 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - createPlugin, - createRoutableExtension, - createRouteRef, -} from '@backstage/core'; -import BadgesDevPageComponent from './page'; -const devRouteRef = createRouteRef({ - title: 'Badges (Dev Page)', -}); - -export const badgesDevPlugin = createPlugin({ - id: 'badges-dev', - routes: { - root: devRouteRef, - }, -}); - -export const BadgesDevPage = badgesDevPlugin.provide( - createRoutableExtension({ - component: () => import('./page').then(m => m.default), - mountPoint: devRouteRef, - }), -); +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..5b03f43a58 --- /dev/null +++ b/plugins/badges/src/api/types.ts @@ -0,0 +1,56 @@ +/* + * 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', +}); + +// should probably have this in a "badges-common" package +interface BadgeConfig { + kind?: 'entity'; + label: string; + message: string; + color?: string; + labelColor?: string; + style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; + title?: string; + description?: string; + link?: string; +} + +export interface BadgeSpec { + /** The rendered data */ + badge: BadgeConfig; + /** The configuration data, with placeholders and all */ + config: BadgeConfig; + /** The context used when rendering config -> badge */ + context: object; +} + +export interface Badge { + id: string; + markdown: string; + spec: BadgeSpec; + url: string; +} + +export interface BadgesApi { + getDefinedEntityBadges(entity: Entity): Promise; +} diff --git a/plugins/badges/src/components/BadgesDrawer.tsx b/plugins/badges/src/components/BadgesDrawer.tsx deleted file mode 100644 index 10ae0f861f..0000000000 --- a/plugins/badges/src/components/BadgesDrawer.tsx +++ /dev/null @@ -1,63 +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 React, { useState } from 'react'; -import { Button, Drawer, Grid, Typography } from '@material-ui/core'; -import { CodeSnippet, Content, InfoCard, useApi } from '@backstage/core'; - -const BadgeCard = ({ title, badgeUrl, markdownCode }) => ( - - - - Paste the following snippet in your README.md or other - markdown file for this badge: - - Badge preview - - - -); - -const BadgesDrawerContent = ({ badges, toggleDrawer }) => ( - - - {badges.map((badge, idx) => ( - - ))} - - - - - -); - -export const BadgesDrawer = ({ badges }) => { - const [isOpen, toggleDrawer] = useState(false); - - return ( - <> - - toggleDrawer(false)}> - - - - ); -}; diff --git a/plugins/badges/src/components/EntityBadgesCard.tsx b/plugins/badges/src/components/EntityBadgesCard.tsx deleted file mode 100644 index 5fc0f47556..0000000000 --- a/plugins/badges/src/components/EntityBadgesCard.tsx +++ /dev/null @@ -1,44 +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 React from 'react'; -import { useAsync } from 'react-use'; -import { Typography } from '@material-ui/core'; -import { CodeSnippet, InfoCard, useApi } from '@backstage/core'; -import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { badgesClientApiRef } from '../BadgesClientApi'; - -export const EntityBadgesCard = () => { - const badgesClientApi = useApi(badgesClientApiRef); - const { entity } = useEntity(); - const { value, loading, error } = useAsync(async () => { - return { - badge: await badgesClientApi.getEntityPoweredByBadgeURL(entity), - markdown: await badgesClientApi.getEntityPoweredByMarkdownCode(entity), - }; - }, [badgesClientApi, entity]); - - return ( - - - Paste the following snippet in your README.md or other - markdown file, to get a powered by backstage badge: - - {value && Powered by Backstage badge} - {value && } - - ); -}; diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx index 8b32bb8d4e..ec9bd35221 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -31,7 +31,7 @@ import { makeStyles } from '@material-ui/core/styles'; import Alert from '@material-ui/lab/Alert'; import React from 'react'; import { useAsync } from 'react-use'; -import { badgesClientApiRef } from '../BadgesClientApi'; +import { badgesApiRef } from '../api'; type Props = { open: boolean; @@ -50,30 +50,31 @@ const useStyles = makeStyles(theme => ({ export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - const badgesClientApi = useApi(badgesClientApiRef); + const badgesApi = useApi(badgesApiRef); const classes = useStyles(); const { value: badges, loading, error } = useAsync(async () => { - const context = await badgesClientApi.getDefaultContext(entity); - return await badgesClientApi.getDefinedBadges('entity', context); - }); + if (open) { + return await badgesApi.getDefinedEntityBadges(entity); + } - const content = (badges || []).map( - ({ id, title, description, url, target, markdown }) => ( -
- - {title || description} -
- {description} -
- - Copy the following snippet of markdown code for the badge: - - -
-
- ), - ); + return []; + }, [badgesApi, entity, open]); + + const content = (badges || []).map(({ id, markdown, spec, url }) => ( +
+ + {spec.badge.title || spec.badge.description || id} +
+ {spec.badge.description} +
+ + Copy the following snippet of markdown code for the badge: + + +
+
+ )); return ( diff --git a/plugins/badges/src/components/EntityBadgesField.tsx b/plugins/badges/src/components/EntityBadgesField.tsx deleted file mode 100644 index 586f9d366d..0000000000 --- a/plugins/badges/src/components/EntityBadgesField.tsx +++ /dev/null @@ -1,52 +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 React from 'react'; -import { useAsync } from 'react-use'; -import { Progress, useApi, WarningPanel } from '@backstage/core'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { badgesClientApiRef } from '../BadgesClientApi'; -import { BadgesDrawer } from './BadgesDrawer'; - -export const EntityBadgesField = () => { - const badgesClientApi = useApi(badgesClientApiRef); - const { entity } = useEntity(); - const { value, loading, error } = useAsync(async () => { - return { - badge: await badgesClientApi.getEntityPoweredByBadgeURL(entity), - markdown: await badgesClientApi.getEntityPoweredByMarkdownCode(entity), - }; - }, [badgesClientApi, entity]); - - const badges = []; - - if (value) { - badges.push({ - title: 'Powered by Backstage', - badgeUrl: value.badge, - markdownCode: value.markdown, - }); - } - - return ( -
- {loading && } - {error && ( - - )} - {badges.length > 0 && } -
- ); -}; diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index e4831158bc..8a617171de 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -20,38 +20,20 @@ import { configApiRef, discoveryApiRef, } from '@backstage/core'; -import { badgesClientApiRef, BadgesClientApi } from './BadgesClientApi'; +import { badgesApiRef, BadgesClient } from './api'; export const badgesPlugin = createPlugin({ id: 'badges', apis: [ createApiFactory({ - api: badgesClientApiRef, + api: badgesApiRef, deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, factory: ({ configApi, discoveryApi }) => - new BadgesClientApi({ configApi, discoveryApi }), + new BadgesClient({ configApi, discoveryApi }), }), ], }); -export const EntityBadgesField = badgesPlugin.provide( - createComponentExtension({ - component: { - lazy: () => - import('./components/EntityBadgesField').then(m => m.EntityBadgesField), - }, - }), -); - -export const EntityBadgesCard = badgesPlugin.provide( - createComponentExtension({ - component: { - lazy: () => - import('./components/EntityBadgesCard').then(m => m.EntityBadgesCard), - }, - }), -); - export const EntityBadgesDialog = badgesPlugin.provide( createComponentExtension({ component: { From 45e26d79566a0a41cf0ba35fff368974e807f6b6 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Mon, 22 Feb 2021 16:50:24 +0100 Subject: [PATCH 09/24] badges-backend: template replace should replace all occurences. Signed-off-by: Andreas Stenius --- app-config.yaml | 9 +++++++-- .../src/lib/BadgeBuilder/DefaultBadgeBuilder.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 9896b2feec..2cab686e96 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -406,10 +406,12 @@ pagerduty: badges: pingback: kind: 'entity' + title: 'View _{entity.metadata.name} in _{app.title}' description: 'Link back to _{app.title}' link: '_{entity_url}' - label: 'View _{entity.metadata.name} in' - message: '_{app.title}' + label: '_{app.title}' + message: '_{entity.kind}: _{entity.metadata.name}' + style: flat-square lifecycle: kind: 'entity' @@ -417,6 +419,7 @@ badges: link: '_{entity_url}' label: 'Lifecycle' message: '_{entity.spec.lifecycle}' + style: for-the-badge owner: kind: 'entity' @@ -426,6 +429,7 @@ badges: label: 'Owner' message: '_{entity.spec.owner}' color: 'blue' + style: social docs: kind: 'entity' @@ -433,3 +437,4 @@ badges: label: 'Documentation' message: '_{entity.metadata.name}' color: 'navyblue' + style: plastic diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index 9b136dcf22..80bca52470 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -88,7 +88,7 @@ export class DefaultBadgeBuilder implements BadgeBuilder { private render(template: string, context: object): string { try { - return interpolate(template.replace('_{', '${'), context); + return interpolate(template.replace(/_{/g, '${'), context); } catch (err) { return `${err} [${template}]`; } From ce4897ff371ad60674bbbd0ca5af3d6fee802cfb Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Mon, 22 Feb 2021 19:02:11 +0100 Subject: [PATCH 10/24] badges: fix type issues. Signed-off-by: Andreas Stenius --- plugins/badges-backend/package.json | 1 + .../lib/BadgeBuilder/DefaultBadgeBuilder.ts | 34 ++++++++------ .../src/lib/BadgeBuilder/types.ts | 11 ++++- .../badges-backend/src/service/router.test.ts | 25 ++++++++--- plugins/badges-backend/src/service/router.ts | 44 +++++++++++-------- plugins/badges-backend/src/utils.ts | 2 +- plugins/badges/dev/index.tsx | 1 - plugins/badges/src/api/BadgesClient.ts | 11 +++-- plugins/badges/src/api/types.ts | 11 ++++- .../src/components/EntityBadgesDialog.tsx | 6 +-- .../EntityContextMenu/EntityContextMenu.tsx | 2 +- 11 files changed, 96 insertions(+), 52 deletions(-) diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 3d77acbe98..ef9e23e16f 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "badge-maker": "^3.3.0", diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index 80bca52470..1e96b6ce5b 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -15,41 +15,46 @@ */ import { Logger } from 'winston'; -import { makeBadge, ValidationError } from 'badge-maker'; -import { BadgeBuilder, BadgeConfig, BadgeOptions } from './types'; +import { makeBadge } from 'badge-maker'; +import { JsonObject } from '@backstage/config'; +import { + BadgeBuilder, + BadgeConfig, + BadgeOptions, + BadgeStyle, + BadgeStyles, +} from './types'; import { interpolate } from '../../utils'; export class DefaultBadgeBuilder implements BadgeBuilder { constructor( private readonly logger: Logger, - private readonly config: { [id: string]: BadgeConfig }, + private readonly config: JsonObject, ) {} - public async getBadgeConfig(badgeId: string): BadgeConfig { - return ( - this.config[badgeId] || - this.config.default || { + public async getBadgeConfig(badgeId: string): Promise { + return ((this.config[badgeId] as unknown) || + (this.config.default as unknown) || { label: 'Unknown badge ID', message: badgeId, color: 'red', - } - ); + }) as BadgeConfig; } - public async createBadge(options: BadgeOptions): string { + public async createBadge(options: BadgeOptions): Promise { const { context, config: badge } = options; const params = { label: this.render(badge.label, context), message: this.render(badge.message, context), color: badge.color || '#36BAA2', - }; + } as BadgeConfig; if (badge.labelColor) { params.labelColor = badge.labelColor; } - if (badge.style) { - params.style = badge.style; + if (BadgeStyles.includes(badge.style as BadgeStyle)) { + params.style = badge.style as BadgeStyle; } switch (options.format) { @@ -90,6 +95,9 @@ export class DefaultBadgeBuilder implements BadgeBuilder { try { return interpolate(template.replace(/_{/g, '${'), context); } catch (err) { + this.logger.info( + `badge template error: ${err}. In template: "${template}"`, + ); return `${err} [${template}]`; } } diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts index ae898d645d..0b0bc3d3b0 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -14,13 +14,22 @@ * limitations under the License. */ +export const BadgeStyles = [ + 'plastic', + 'flat', + 'flat-square', + 'for-the-badge', + 'social', +] as const; +export type BadgeStyle = typeof BadgeStyles[number]; + export interface BadgeConfig { kind?: 'entity'; label: string; message: string; color?: string; labelColor?: string; - style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; + style?: BadgeStyle; title?: string; description?: string; link?: string; diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 94a0b099c0..90d91eb6f3 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -14,12 +14,23 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; -import express from 'express'; -import request from 'supertest'; -import { BadgesApi } from '../api'; +import * as winston from 'winston'; +import { + loadBackendConfig, + SingleHostDiscovery, +} from '@backstage/backend-common'; import { createRouter } from './router'; -import {} from '../api/types'; -describe('createRouter', () => {}); +describe('createRouter', () => { + it('works', async () => { + const logger = winston.createLogger(); + const config = await loadBackendConfig({ logger, argv: [] }); + const discovery = SingleHostDiscovery.fromConfig(config); + const router = await createRouter({ + config, + logger, + discovery, + }); + expect(router).toBeDefined(); + }); +}); diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index d0013d8318..81e9be069a 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -22,11 +22,17 @@ import { errorHandler, PluginEndpointDiscovery, } from '@backstage/backend-common'; -import { Config } from '@backstage/config'; -import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; +import { Entity } from '@backstage/catalog-model'; +import { Config, JsonObject } from '@backstage/config'; +import { + BadgeBuilder, + BadgeStyle, + BadgeStyles, + DefaultBadgeBuilder, +} from '../lib/BadgeBuilder'; export interface RouterOptions { - badgeBuilder: BadgeBuilder; + badgeBuilder?: BadgeBuilder; logger: Logger; config: Config; discovery: PluginEndpointDiscovery; @@ -39,10 +45,13 @@ export async function createRouter( const logger = options.logger.child({ plugin: 'badges' }); const title = options.config.getString('app.title') || 'Backstage'; const catalogUrl = `${options.config.getString('app.baseUrl')}/catalog`; - const badgesConfig = options.config.getOptional('badges') ?? {}; + const badgesConfig = (options.config.getOptional('badges') ?? + {}) as JsonObject; const badgeBuilder = options.badgeBuilder || new DefaultBadgeBuilder(logger, badgesConfig); + logger.debug(`loading badges`); + router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => { const { badgeId } = req.params; let badge = await badgeBuilder.getBadgeConfig(badgeId); @@ -55,7 +64,7 @@ export async function createRouter( } const entityUri = getEntityUri(req.params); - const entity = await getEntity(logger, options.discovery, entityUri); + const entity = await getEntity(options.discovery, entityUri); if (!entity) { res.status(400).send(`Unknown entity`); return; @@ -67,8 +76,8 @@ export async function createRouter( format = 'application/json'; } - if (req.query.style) { - badge.style = req.query.style; + if (BadgeStyles.includes(req.query.style as BadgeStyle)) { + badge.style = req.query.style as BadgeStyle; } const data = await badgeBuilder.createBadge({ @@ -90,23 +99,20 @@ export async function createRouter( return router; } -function getEntityUri(params) { +function getEntityUri(params: JsonObject): string { const { kind, namespace, name } = params; return `${kind}/${namespace}/${name}`; } -async function getEntity(logger, discovery, entityUri) { +async function getEntity( + discovery: PluginEndpointDiscovery, + entityUri: string, +): Promise { const catalogUrl = await discovery.getBaseUrl('catalog'); - try { - const entity = (await ( - await fetch(`${catalogUrl}/entities/by-name/${entityUri}`) - ).json()) as Entity; + const entity = (await ( + await fetch(`${catalogUrl}/entities/by-name/${entityUri}`) + ).json()) as Entity; - return entity; - } catch (err) { - const msg = `Unable to get entity ${entityUri}, error ${err}`; - logger.info(msg); - return null; - } + return entity; } diff --git a/plugins/badges-backend/src/utils.ts b/plugins/badges-backend/src/utils.ts index e9090905e3..0f4773170e 100644 --- a/plugins/badges-backend/src/utils.ts +++ b/plugins/badges-backend/src/utils.ts @@ -17,7 +17,7 @@ /** * adapted from https://stackoverflow.com/a/41015840/444060 */ -export function interpolate(template, params) { +export function interpolate(template: string, params: object): string { const names = Object.keys(params); const vals = Object.values(params); // eslint-disable-next-line no-new-func diff --git a/plugins/badges/dev/index.tsx b/plugins/badges/dev/index.tsx index 1f545a777e..1616edacb4 100644 --- a/plugins/badges/dev/index.tsx +++ b/plugins/badges/dev/index.tsx @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; import { badgesPlugin } from '../src/plugin'; diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index 9c0387b58e..2cd68ec909 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -18,7 +18,7 @@ import { generatePath } from 'react-router'; import { ConfigApi, DiscoveryApi } from '@backstage/core'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { entityRoute } from '@backstage/plugin-catalog-react'; -import { BadgesApi, BadgeConfig, BadgeSpec } from './types'; +import { Badge, BadgesApi, BadgeConfig, BadgeSpec } from './types'; export class BadgesClient implements BadgesApi { private readonly configApi: ConfigApi; @@ -29,7 +29,7 @@ export class BadgesClient implements BadgesApi { this.discoveryApi = options.discoveryApi; } - public async getDefinedEntityBadges(entity: Entity): Badge[] { + public async getDefinedEntityBadges(entity: Entity): Promise { const badges = []; const badgesConfig = this.configApi.getOptional('badges') ?? {}; const entityBadgeUri = await this.getEntityBadgeUri(entity); @@ -46,7 +46,10 @@ export class BadgesClient implements BadgesApi { return badges; } - private async getBadgeInfo(entityBadgeUri: string, badgeId: string): Badge { + private async getBadgeInfo( + entityBadgeUri: string, + badgeId: string, + ): Promise { const badgeUrl = `${entityBadgeUri}/${badgeId}`; const spec = (await ( await fetch(`${badgeUrl}?format=json`) @@ -60,7 +63,7 @@ export class BadgesClient implements BadgesApi { }; } - private async getEntityBadgeUri(entity: Entity): string { + private async getEntityBadgeUri(entity: Entity): Promise { const routeParams = this.getEntityRouteParams(entity); const path = generatePath(entityRoute.path, routeParams); return `${await this.discoveryApi.getBaseUrl('badges')}/entity/${path}`; diff --git a/plugins/badges/src/api/types.ts b/plugins/badges/src/api/types.ts index 5b03f43a58..00f5f62a3b 100644 --- a/plugins/badges/src/api/types.ts +++ b/plugins/badges/src/api/types.ts @@ -22,14 +22,21 @@ export const badgesApiRef = createApiRef({ description: 'Used to make requests to the badges backend', }); +export type BadgeStyle = + | 'plastic' + | 'flat' + | 'flat-square' + | 'for-the-badge' + | 'social'; + // should probably have this in a "badges-common" package -interface BadgeConfig { +export interface BadgeConfig { kind?: 'entity'; label: string; message: string; color?: string; labelColor?: string; - style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; + style?: BadgeStyle; title?: string; description?: string; link?: string; diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx index ec9bd35221..3a667fb338 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -39,13 +39,13 @@ type Props = { entity: Entity; }; -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles({ codeBlock: { '& code': { whiteSpace: 'pre-wrap', }, }, -})); +}); export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { const theme = useTheme(); @@ -70,7 +70,7 @@ export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { Copy the following snippet of markdown code for the badge: - +
diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index a8da917fd8..b9af9b307e 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -35,7 +35,7 @@ const useStyles = makeStyles({ }); type Props = { - onShowBadgesDialog: () => void; + onShowBadgesDialog?: () => void; onUnregisterEntity: () => void; }; From ac17aeb35d7dd8fdfbd3eb5cc62c0e85b09fd8d2 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Tue, 23 Feb 2021 13:03:43 +0100 Subject: [PATCH 11/24] badges: load default badges from code rather than config. #4457. Signed-off-by: Andreas Stenius --- app-config.yaml | 29 +------- packages/backend/src/plugins/badges.ts | 4 +- plugins/badges-backend/config.d.ts | 27 +++---- plugins/badges-backend/src/badges.ts | 54 ++++++++++++++ plugins/badges-backend/src/index.ts | 2 + .../lib/BadgeBuilder/DefaultBadgeBuilder.ts | 44 +++++++----- .../src/lib/BadgeBuilder/types.ts | 26 ++----- plugins/badges-backend/src/service/router.ts | 72 ++++++++++++++++--- plugins/badges-backend/src/types.ts | 48 +++++++++++++ plugins/badges/config.d.ts | 27 ------- plugins/badges/package.json | 6 +- plugins/badges/src/api/BadgesClient.ts | 60 ++++------------ plugins/badges/src/api/types.ts | 43 ++++++----- .../src/components/EntityBadgesDialog.tsx | 36 ++++++---- plugins/badges/src/plugin.ts | 6 +- 15 files changed, 278 insertions(+), 206 deletions(-) create mode 100644 plugins/badges-backend/src/badges.ts create mode 100644 plugins/badges-backend/src/types.ts delete mode 100644 plugins/badges/config.d.ts diff --git a/app-config.yaml b/app-config.yaml index 2cab686e96..21b3c3199d 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -404,37 +404,10 @@ pagerduty: # sample badges badges: - pingback: - kind: 'entity' - title: 'View _{entity.metadata.name} in _{app.title}' - description: 'Link back to _{app.title}' - link: '_{entity_url}' - label: '_{app.title}' - message: '_{entity.kind}: _{entity.metadata.name}' - style: flat-square - lifecycle: kind: 'entity' description: 'Entity lifecycle badge' link: '_{entity_url}' - label: 'Lifecycle' + label: 'lifecycle' message: '_{entity.spec.lifecycle}' style: for-the-badge - - owner: - kind: 'entity' - title: 'Resource Owner' - description: 'Entity owner badge' - link: '_{entity_url}' - label: 'Owner' - message: '_{entity.spec.owner}' - color: 'blue' - style: social - - docs: - kind: 'entity' - link: '_{entity_url}/docs' - label: 'Documentation' - message: '_{entity.metadata.name}' - color: 'navyblue' - style: plastic diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts index 132a90e38e..4e2eee74e5 100644 --- a/packages/backend/src/plugins/badges.ts +++ b/packages/backend/src/plugins/badges.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createRouter } from '@backstage/plugin-badges-backend'; +import { createRouter, badges } from '@backstage/plugin-badges-backend'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ @@ -22,5 +22,5 @@ export default async function createPlugin({ config, discovery, }: PluginEnvironment) { - return await createRouter({ logger, config, discovery }); + return await createRouter({ logger, config, discovery, badges }); } diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts index b13f46e1fa..5870b650a0 100644 --- a/plugins/badges-backend/config.d.ts +++ b/plugins/badges-backend/config.d.ts @@ -16,7 +16,10 @@ export interface Config { /** - * Define all badges. + * Define custom badges. By default, the badges are declared in + * code, and passed to the badges backend `createRouter`, which + * merges them with any additional badges defined in this + * configuration. * * The `label` and `message` fields may use templating to support * dynamic content, based on context. Use same syntax as for @@ -33,7 +36,9 @@ export interface Config { * Useful when using templating for label and/or message if they * use context data only available for a certain kind of badge. * - * Available badge kinds: + * Context per badge kind. + * + * Entity badges: * * * `entity` The entity data is available as `entity` in the template. * * `entity_url` The (frontend) URL to view the entity in Backstage. @@ -41,14 +46,13 @@ export interface Config { * Default context for all badges: * * * `app.title` As configured or defaults to "Backstage". + * * `badge_url` The URL to the badge image. * - * @visibility frontend */ kind?: 'entity'; /** * The badge label. - * */ label: string; @@ -70,21 +74,17 @@ export interface Config { /** * Visual design of the badge. One of: 'plastic', 'flat', 'flat-square', * 'for-the-badge' or 'social'. + * + * Default: 'flat' + * */ style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; /** - * Badge title, used as tooltip text in the markdown code. + * Badge description, used as prefix on the alt text in the markdown code. * - * @visibility frontend - */ - title?: string; - - /** - * Badge description, used as alt text in the markdown code. * Defaults to badge id. * - * @visibility frontend */ description?: string; @@ -94,7 +94,8 @@ export interface Config { * For `entity` badges, there is a `entity_url` in the context * that could be appropriate to use here. * - * @visibility frontend + * Defaults to the `entity_url`, set to falsey value to disable the link. + * */ link?: string; }; diff --git a/plugins/badges-backend/src/badges.ts b/plugins/badges-backend/src/badges.ts new file mode 100644 index 0000000000..253b6c0166 --- /dev/null +++ b/plugins/badges-backend/src/badges.ts @@ -0,0 +1,54 @@ +/* + * 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 } from './types'; + +export const badges: Badge[] = [ + { + id: 'pingback', + kind: 'entity', + description: 'Link back to _{app.title}', + label: '_{app.title}', + message: '_{entity.kind}: _{entity.metadata.name}', + style: 'flat-square', + }, + { + id: 'lifecycle', + kind: 'entity', + description: 'Entity lifecycle badge', + label: 'lifecycle', + message: '_{entity.spec.lifecycle}', + style: 'flat-square', + }, + { + id: 'owner', + kind: 'entity', + description: 'Entity owner badge', + label: 'owner', + message: '_{entity.spec.owner}', + color: 'blue', + style: 'flat-square', + }, + { + id: 'docs', + kind: 'entity', + link: '_{entity_url}/docs', + label: 'docs', + message: '_{entity.metadata.name}', + color: 'navyblue', + style: 'flat-square', + }, +]; diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts index 01917c0c80..dc08144706 100644 --- a/plugins/badges-backend/src/index.ts +++ b/plugins/badges-backend/src/index.ts @@ -15,4 +15,6 @@ */ export * from './service/router'; +export * from './types'; export * from './utils'; +export * from './badges'; diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index 1e96b6ce5b..dad928e8ec 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -17,28 +17,31 @@ import { Logger } from 'winston'; import { makeBadge } from 'badge-maker'; import { JsonObject } from '@backstage/config'; -import { - BadgeBuilder, - BadgeConfig, - BadgeOptions, - BadgeStyle, - BadgeStyles, -} from './types'; +import { BadgeBuilder, BadgeOptions } from './types'; +import { Badge, BadgeStyle, BadgeStyles } from '../../types'; import { interpolate } from '../../utils'; export class DefaultBadgeBuilder implements BadgeBuilder { constructor( private readonly logger: Logger, private readonly config: JsonObject, - ) {} + ) { + for (const [badgeId, badge] of Object.entries(config)) { + badge.id = badgeId; + } + } - public async getBadgeConfig(badgeId: string): Promise { + public async getAllBadgeConfigs(): Promise { + return Object.values(this.config) as Badge[]; + } + + public async getBadgeConfig(badgeId: string): Promise { return ((this.config[badgeId] as unknown) || (this.config.default as unknown) || { label: 'Unknown badge ID', message: badgeId, color: 'red', - }) as BadgeConfig; + }) as Badge; } public async createBadge(options: BadgeOptions): Promise { @@ -47,7 +50,7 @@ export class DefaultBadgeBuilder implements BadgeBuilder { label: this.render(badge.label, context), message: this.render(badge.message, context), color: badge.color || '#36BAA2', - } as BadgeConfig; + } as Badge; if (badge.labelColor) { params.labelColor = badge.labelColor; @@ -59,15 +62,15 @@ export class DefaultBadgeBuilder implements BadgeBuilder { switch (options.format) { case 'json': - if (badge.title) { - params.title = this.render(badge.title, context); - } - if (badge.description) { - params.description = this.render(badge.description, context); - } 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, @@ -101,4 +104,11 @@ export class DefaultBadgeBuilder implements BadgeBuilder { return `${err} [${template}]`; } } + + private getMarkdownCode(params: Badge, badge_url: string): string { + const alt_text = `${params.description}, ${params.label}: ${params.message}`; + const tooltip = params.description ? ` "${params.description}"` : ''; + const img = `![${alt_text}](${badge_url}${tooltip})`; + return params.link ? `[${img}](${params.link})` : img; + } } diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts index 0b0bc3d3b0..d082f4f843 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -14,34 +14,16 @@ * limitations under the License. */ -export const BadgeStyles = [ - 'plastic', - 'flat', - 'flat-square', - 'for-the-badge', - 'social', -] as const; -export type BadgeStyle = typeof BadgeStyles[number]; - -export interface BadgeConfig { - kind?: 'entity'; - label: string; - message: string; - color?: string; - labelColor?: string; - style?: BadgeStyle; - title?: string; - description?: string; - link?: string; -} +import { Badge } from '../../types'; export type BadgeOptions = { context: object; - config: BadgeConfig; + config: Badge; format: 'svg' | 'json'; }; export type BadgeBuilder = { createBadge(options: BadgeOptions): Promise; - getBadgeConfig(badgeId: string): Promise; + getBadgeConfig(badgeId: string): Promise; + getAllBadgeConfigs(): Promise; }; diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 81e9be069a..a7e3c018e0 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -24,15 +24,12 @@ import { } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { Config, JsonObject } from '@backstage/config'; -import { - BadgeBuilder, - BadgeStyle, - BadgeStyles, - DefaultBadgeBuilder, -} from '../lib/BadgeBuilder'; +import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; +import { Badge, BadgeStyle, BadgeStyles } from '../types'; export interface RouterOptions { badgeBuilder?: BadgeBuilder; + badges?: Badge[]; logger: Logger; config: Config; discovery: PluginEndpointDiscovery; @@ -47,10 +44,58 @@ export async function createRouter( const catalogUrl = `${options.config.getString('app.baseUrl')}/catalog`; const badgesConfig = (options.config.getOptional('badges') ?? {}) as JsonObject; + + for (const badge of options.badges || []) { + if (!badge.id) { + logger.warning(`badge without "id": ${JSON.stringify(badge, null, 2)}`); + } else if (!badgesConfig[badge.id]) { + badgesConfig[badge.id] = badge; + logger.info(`register builtin badge: ${badge.id}`); + } else { + logger.info(`builtin badge replaced from configuration: ${badge.id}`); + } + } + const badgeBuilder = options.badgeBuilder || new DefaultBadgeBuilder(logger, badgesConfig); - logger.debug(`loading badges`); + 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 }, + 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', + }), + ); + } + } + + res.setHeader('Content-Type', 'application/json'); + res.status(200).send(`[${specs.join(',\n')}]`); + }); router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => { const { badgeId } = req.params; @@ -66,7 +111,7 @@ export async function createRouter( const entityUri = getEntityUri(req.params); const entity = await getEntity(options.discovery, entityUri); if (!entity) { - res.status(400).send(`Unknown entity`); + res.status(404).send(`Unknown entity`); return; } @@ -80,12 +125,21 @@ export async function createRouter( badge.style = req.query.style as BadgeStyle; } + const badge_url = [ + `${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: `${catalogUrl}/${entityUri}`, + entity_url, }, format: format === 'application/json' ? 'json' : 'svg', }); diff --git a/plugins/badges-backend/src/types.ts b/plugins/badges-backend/src/types.ts new file mode 100644 index 0000000000..9ea720f609 --- /dev/null +++ b/plugins/badges-backend/src/types.ts @@ -0,0 +1,48 @@ +/* + * 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 const BadgeStyles = [ + 'plastic', + 'flat', + 'flat-square', + 'for-the-badge', + 'social', +] as const; +export type BadgeStyle = typeof BadgeStyles[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?: '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; +} diff --git a/plugins/badges/config.d.ts b/plugins/badges/config.d.ts deleted file mode 100644 index 000eaa0b9b..0000000000 --- a/plugins/badges/config.d.ts +++ /dev/null @@ -1,27 +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. - */ - -export interface Config { - // defined with doc in badges-backend - badges?: { - [badgeId: string]: { - /** - * @visibility frontend - */ - kind?: 'entity'; - }; - }; -} diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 39a220a534..65e09e2524 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -45,8 +45,6 @@ "msw": "^0.21.2" }, "files": [ - "dist", - "config.d.ts" - ], - "configSchema": "config.d.ts" + "dist" + ] } diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index 2cd68ec909..4160278d10 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -15,58 +15,33 @@ */ import { generatePath } from 'react-router'; -import { ConfigApi, DiscoveryApi } from '@backstage/core'; +import { DiscoveryApi } from '@backstage/core'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { entityRoute } from '@backstage/plugin-catalog-react'; -import { Badge, BadgesApi, BadgeConfig, BadgeSpec } from './types'; +import { BadgesApi, BadgeSpec } from './types'; export class BadgesClient implements BadgesApi { - private readonly configApi: ConfigApi; private readonly discoveryApi: DiscoveryApi; - constructor(options: { configApi: ConfigApi; discoveryApi: DiscoveryApi }) { - this.configApi = options.configApi; + constructor(options: { discoveryApi: DiscoveryApi }) { this.discoveryApi = options.discoveryApi; } - public async getDefinedEntityBadges(entity: Entity): Promise { - const badges = []; - const badgesConfig = this.configApi.getOptional('badges') ?? {}; - const entityBadgeUri = await this.getEntityBadgeUri(entity); + public async getEntityBadgeSpecs(entity: Entity): Promise { + const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity); + const specs = (await ( + await fetch(entityBadgeSpecsUrl) + ).json()) as BadgeSpec[]; - for (const [badgeId, badge] of Object.entries(badgesConfig)) { - if (!badge.kind || badge.kind === 'entity') { - const info = await this.getBadgeInfo(entityBadgeUri, badgeId); - if (info) { - badges.push(info); - } - } - } - - return badges; + return specs; } - private async getBadgeInfo( - entityBadgeUri: string, - badgeId: string, - ): Promise { - const badgeUrl = `${entityBadgeUri}/${badgeId}`; - const spec = (await ( - await fetch(`${badgeUrl}?format=json`) - ).json()) as BadgeSpec; - - return { - id: badgeId, - markdown: this.getBadgeMarkdownCode(badgeUrl, spec.badge), - spec, - url: badgeUrl, - }; - } - - private async getEntityBadgeUri(entity: Entity): Promise { + 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}`; + return `${await this.discoveryApi.getBaseUrl( + 'badges', + )}/entity/${path}/badge-specs`; } private getEntityRouteParams(entity: Entity) { @@ -77,13 +52,4 @@ export class BadgesClient implements BadgesApi { name: entity.metadata.name, }; } - - private getBadgeMarkdownCode(badgeUrl: string, badge: BadgeConfig): string { - const title = badge.title ? ` "${badge.title}"` : ''; - const img = `![${badge.description || 'badge'}](${badgeUrl}${title})`; - if (!badge.link) { - return img; - } - return `[${img}](${badge.link})`; - } } diff --git a/plugins/badges/src/api/types.ts b/plugins/badges/src/api/types.ts index 00f5f62a3b..d3ba234e44 100644 --- a/plugins/badges/src/api/types.ts +++ b/plugins/badges/src/api/types.ts @@ -29,35 +29,42 @@ export type BadgeStyle = | 'for-the-badge' | 'social'; -// should probably have this in a "badges-common" package -export interface BadgeConfig { +interface BadgeParams { + color?: string; + description?: string; kind?: 'entity'; label: string; - message: string; - color?: string; labelColor?: string; - style?: BadgeStyle; - title?: string; - description?: string; link?: string; + message: string; + style?: BadgeStyle; +} + +interface Badge extends BadgeParams { + markdown: string; +} + +interface BadgeConfig extends BadgeParams { + id: string; } export interface BadgeSpec { - /** The rendered data */ - badge: BadgeConfig; + /** The rendered fields, markdown code */ + badge: Badge; + /** The configuration data, with placeholders and all */ config: BadgeConfig; - /** The context used when rendering config -> badge */ - context: object; -} -export interface Badge { - id: string; - markdown: string; - spec: BadgeSpec; - url: string; + /** The context used when rendering config -> badge */ + context: { + // here is more, but only badge_url we care about + badge_url: string; + }; + + format: 'json'; // or 'svg', but we'll never see that as structured + // data, only as an svg element } export interface BadgesApi { - getDefinedEntityBadges(entity: Entity): Promise; + getEntityBadgeSpecs(entity: Entity): Promise; } diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx index 3a667fb338..4fac612ec4 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -55,26 +55,32 @@ export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { const { value: badges, loading, error } = useAsync(async () => { if (open) { - return await badgesApi.getDefinedEntityBadges(entity); + return await badgesApi.getEntityBadgeSpecs(entity); } return []; }, [badgesApi, entity, open]); - const content = (badges || []).map(({ id, markdown, spec, url }) => ( -
- - {spec.badge.title || spec.badge.description || id} -
- {spec.badge.description} -
- - Copy the following snippet of markdown code for the badge: - - -
-
- )); + const content = (badges || []).map( + ({ + badge: { description, markdown }, + config: { id }, + context: { badge_url }, + }) => ( +
+ + {description} +
+ {description} +
+ + Copy the following snippet of markdown code for the badge: + + +
+
+ ), + ); return ( diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index 8a617171de..92932aa356 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -17,7 +17,6 @@ import { createApiFactory, createComponentExtension, createPlugin, - configApiRef, discoveryApiRef, } from '@backstage/core'; import { badgesApiRef, BadgesClient } from './api'; @@ -27,9 +26,8 @@ export const badgesPlugin = createPlugin({ apis: [ createApiFactory({ api: badgesApiRef, - deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, - factory: ({ configApi, discoveryApi }) => - new BadgesClient({ configApi, discoveryApi }), + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new BadgesClient({ discoveryApi }), }), ], }); From 38cc67e55198dc0e35370bcfea1c71e0880f1f91 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Tue, 23 Feb 2021 13:24:51 +0100 Subject: [PATCH 12/24] badge: type tweaks. Signed-off-by: Andreas Stenius --- .../lib/BadgeBuilder/DefaultBadgeBuilder.ts | 22 +++++++++++-------- .../src/lib/BadgeBuilder/types.ts | 10 ++++++++- plugins/badges-backend/src/service/router.ts | 7 +++--- plugins/badges-backend/src/types.ts | 6 +++++ 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index dad928e8ec..c1ff60056c 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -16,32 +16,36 @@ import { Logger } from 'winston'; import { makeBadge } from 'badge-maker'; -import { JsonObject } from '@backstage/config'; import { BadgeBuilder, BadgeOptions } from './types'; -import { Badge, BadgeStyle, BadgeStyles } from '../../types'; +import { Badge, BadgeConfig, BadgeStyle, BadgeStyles } from '../../types'; import { interpolate } from '../../utils'; export class DefaultBadgeBuilder implements BadgeBuilder { constructor( private readonly logger: Logger, - private readonly config: JsonObject, + private readonly config: BadgeConfig, ) { for (const [badgeId, badge] of Object.entries(config)) { - badge.id = badgeId; + if (badge) { + badge.id = badgeId; + } } } public async getAllBadgeConfigs(): Promise { - return Object.values(this.config) as Badge[]; + return Object.values(this.config); } public async getBadgeConfig(badgeId: string): Promise { - return ((this.config[badgeId] as unknown) || - (this.config.default as unknown) || { + return ( + this.config[badgeId] || + this.config.default || + ({ label: 'Unknown badge ID', message: badgeId, color: 'red', - }) as Badge; + } as Badge) + ); } public async createBadge(options: BadgeOptions): Promise { @@ -69,7 +73,7 @@ export class DefaultBadgeBuilder implements BadgeBuilder { params.description = badge.description ? this.render(badge.description, context) : badge.id; - params.markdown = this.getMarkdownCode(params, context.badge_url); + params.markdown = this.getMarkdownCode(params, context.badge_url!); return JSON.stringify( { diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts index d082f4f843..e6cb3213e9 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -14,10 +14,18 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { Badge } from '../../types'; export type BadgeOptions = { - context: object; + context: { + app?: { + title: string; + }; + entity?: Entity; + entity_url?: string; + badge_url?: string; + }; config: Badge; format: 'svg' | 'json'; }; diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index a7e3c018e0..694d53aed0 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -25,7 +25,7 @@ import { import { Entity } from '@backstage/catalog-model'; import { Config, JsonObject } from '@backstage/config'; import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; -import { Badge, BadgeStyle, BadgeStyles } from '../types'; +import { Badge, BadgeConfig, BadgeStyle, BadgeStyles } from '../types'; export interface RouterOptions { badgeBuilder?: BadgeBuilder; @@ -43,7 +43,7 @@ export async function createRouter( const title = options.config.getString('app.title') || 'Backstage'; const catalogUrl = `${options.config.getString('app.baseUrl')}/catalog`; const badgesConfig = (options.config.getOptional('badges') ?? - {}) as JsonObject; + {}) as BadgeConfig; for (const badge of options.badges || []) { if (!badge.id) { @@ -70,6 +70,7 @@ export async function createRouter( const context = { app: { title }, + badge_url: '', entity, entity_url, }; @@ -81,7 +82,7 @@ export async function createRouter( context.badge_url = [ `${req.protocol}://`, req.headers.host, - req.originalUrl.replace(/badge-specs$/, badge.id), + req.originalUrl.replace(/badge-specs$/, badge.id!), ].join(''); specs.push( await badgeBuilder.createBadge({ diff --git a/plugins/badges-backend/src/types.ts b/plugins/badges-backend/src/types.ts index 9ea720f609..00a3d40756 100644 --- a/plugins/badges-backend/src/types.ts +++ b/plugins/badges-backend/src/types.ts @@ -45,4 +45,10 @@ 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; } From 0f30b99bc29102c010fdaae1b8d5427d35697369 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Tue, 23 Feb 2021 13:43:55 +0100 Subject: [PATCH 13/24] badges: cleanup. enforce all template fields to be lower case. thanx @adamdmharvey Signed-off-by: Andreas Stenius --- app-config.yaml | 10 ---------- plugins/badges-backend/src/badges.ts | 8 +++----- .../src/lib/BadgeBuilder/DefaultBadgeBuilder.ts | 7 +++++-- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 21b3c3199d..465c7b0049 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -401,13 +401,3 @@ homepage: timezone: 'Asia/Tokyo' pagerduty: eventsBaseUrl: 'https://events.pagerduty.com/v2' - -# sample badges -badges: - lifecycle: - kind: 'entity' - description: 'Entity lifecycle badge' - link: '_{entity_url}' - label: 'lifecycle' - message: '_{entity.spec.lifecycle}' - style: for-the-badge diff --git a/plugins/badges-backend/src/badges.ts b/plugins/badges-backend/src/badges.ts index 253b6c0166..c830fbcfa2 100644 --- a/plugins/badges-backend/src/badges.ts +++ b/plugins/badges-backend/src/badges.ts @@ -20,9 +20,9 @@ export const badges: Badge[] = [ { id: 'pingback', kind: 'entity', - description: 'Link back to _{app.title}', - label: '_{app.title}', - message: '_{entity.kind}: _{entity.metadata.name}', + description: 'Link to _{entity.metadata.name} in _{app.title}', + label: '_{entity.kind}', + message: '_{entity.metadata.name}', style: 'flat-square', }, { @@ -39,7 +39,6 @@ export const badges: Badge[] = [ description: 'Entity owner badge', label: 'owner', message: '_{entity.spec.owner}', - color: 'blue', style: 'flat-square', }, { @@ -48,7 +47,6 @@ export const badges: Badge[] = [ link: '_{entity_url}/docs', label: 'docs', message: '_{entity.metadata.name}', - color: 'navyblue', style: 'flat-square', }, ]; diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index c1ff60056c..76d2645b51 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -100,7 +100,7 @@ export class DefaultBadgeBuilder implements BadgeBuilder { private render(template: string, context: object): string { try { - return interpolate(template.replace(/_{/g, '${'), context); + return interpolate(template.replace(/_{/g, '${'), context).toLowerCase(); } catch (err) { this.logger.info( `badge template error: ${err}. In template: "${template}"`, @@ -110,7 +110,10 @@ export class DefaultBadgeBuilder implements BadgeBuilder { } private getMarkdownCode(params: Badge, badge_url: string): string { - const alt_text = `${params.description}, ${params.label}: ${params.message}`; + let alt_text = `${params.label}: ${params.message}`; + if (params.description !== params.label) { + alt_text = `${params.description}, ${alt_text}`; + } const tooltip = params.description ? ` "${params.description}"` : ''; const img = `![${alt_text}](${badge_url}${tooltip})`; return params.link ? `[${img}](${params.link})` : img; From d80bdf03246c18d8021adbc3c63570b3cd0aa308 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Tue, 23 Feb 2021 13:59:14 +0100 Subject: [PATCH 14/24] badges: touch up readmes. Signed-off-by: Andreas Stenius --- .changeset/red-peas-smoke.md | 6 ++++++ plugins/badges-backend/README.md | 17 +++++++---------- plugins/badges/README.md | 12 +++++++++--- 3 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 .changeset/red-peas-smoke.md diff --git a/.changeset/red-peas-smoke.md b/.changeset/red-peas-smoke.md new file mode 100644 index 0000000000..4640e12b48 --- /dev/null +++ b/.changeset/red-peas-smoke.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-badges': patch +'@backstage/plugin-badges-backend': patch +--- + +New plugin to serve badges. diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index cbd2dcf0c7..ac85267ca1 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -6,24 +6,21 @@ the badges, in svg. ## Setup -Define which badges to offer in the backend api by declaring them in -the app-config, under a `badges` key. Example: +The list of all badges to offer are passed to the badges-backend +`createRouter()`. + +You may also add/redefine badges in the `app-config.yaml`, under a +`badges` key. Example: ```yaml badges: docs: kind: 'entity' target: '_{entity_url}/docs' - label: 'Documentation' + label: 'docs' message: '_{entity.metadata.name}' color: 'navyblue' - - lifecycle: - kind: 'entity' - description: 'Entity lifecycle badge' - target: '_{entity_url}' - label: 'Lifecycle' - message: '_{entity.spec.lifecycle}' + style: for-the-badge ``` ## Links diff --git a/plugins/badges/README.md b/plugins/badges/README.md index 5e89e1294f..221d3a0e90 100644 --- a/plugins/badges/README.md +++ b/plugins/badges/README.md @@ -1,8 +1,14 @@ -# badges +# Badges -Welcome to the badges plugin! +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. -_This plugin was created through the Backstage CLI_ +The available badges are setup in the `badges-backend` plugin, see +link below. + +To get markdown code for the badges, access the `Badges` context menu +(three dots in the upper right corner) of an entity page. ## Links From 6a68e79217880c967608fd3b70c6e54dda40cf9f Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Tue, 2 Mar 2021 12:13:10 +0100 Subject: [PATCH 15/24] badges-backend: drop support for defining badges in app config. Signed-off-by: Andreas Stenius --- packages/backend/src/plugins/badges.ts | 12 +- plugins/badges-backend/README.md | 14 --- plugins/badges-backend/config.d.ts | 103 ------------------ plugins/badges-backend/package.json | 6 +- plugins/badges-backend/src/badges.ts | 2 +- .../lib/BadgeBuilder/DefaultBadgeBuilder.ts | 26 +++-- plugins/badges-backend/src/service/router.ts | 21 +--- plugins/badges-backend/src/types.ts | 4 +- 8 files changed, 33 insertions(+), 155 deletions(-) delete mode 100644 plugins/badges-backend/config.d.ts diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts index 4e2eee74e5..cea58d8737 100644 --- a/packages/backend/src/plugins/badges.ts +++ b/packages/backend/src/plugins/badges.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { createRouter, badges } from '@backstage/plugin-badges-backend'; +import { + createRouter, + createDefaultBadges, +} from '@backstage/plugin-badges-backend'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ @@ -22,5 +25,10 @@ export default async function createPlugin({ config, discovery, }: PluginEnvironment) { - return await createRouter({ logger, config, discovery, badges }); + return await createRouter({ + logger, + config, + discovery, + badges: createDefaultBadges(), + }); } diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index ac85267ca1..94008c4ba0 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -9,20 +9,6 @@ the badges, in svg. The list of all badges to offer are passed to the badges-backend `createRouter()`. -You may also add/redefine badges in the `app-config.yaml`, under a -`badges` key. Example: - -```yaml -badges: - docs: - kind: 'entity' - target: '_{entity_url}/docs' - label: 'docs' - message: '_{entity.metadata.name}' - color: 'navyblue' - style: for-the-badge -``` - ## Links - [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/badges) diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts deleted file mode 100644 index 5870b650a0..0000000000 --- a/plugins/badges-backend/config.d.ts +++ /dev/null @@ -1,103 +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. - */ - -export interface Config { - /** - * Define custom badges. By default, the badges are declared in - * code, and passed to the badges backend `createRouter`, which - * merges them with any additional badges defined in this - * configuration. - * - * 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 `_` instead of `$`, - * e.g. `_{context.variable.name}`. - * - */ - badges?: { - [badgeId: string]: { - /** - * (Optional) Badge kind. - * - * Restrict badge usage to a certain kind only. - * Useful when using templating for label and/or message if they - * use context data only available for a certain kind of badge. - * - * Context per badge kind. - * - * Entity badges: - * - * * `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". - * * `badge_url` The URL to the badge image. - * - */ - kind?: 'entity'; - - /** - * The badge label. - */ - label: string; - - /** - * The badge message. - */ - message: string; - - /** - * The message color. Default: `#36BAA2`. - */ - color?: string; - - /** - * The label color. Default: `gray`. - */ - labelColor?: string; - - /** - * Visual design of the badge. One of: 'plastic', 'flat', 'flat-square', - * 'for-the-badge' or 'social'. - * - * Default: 'flat' - * - */ - style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; - - /** - * Badge description, used as prefix on the alt text in the markdown code. - * - * Defaults to badge id. - * - */ - description?: string; - - /** - * Badge link URL, turns badge into a link in the markdown code. - * - * For `entity` badges, there is a `entity_url` in the context - * that could be appropriate to use here. - * - * Defaults to the `entity_url`, set to falsey value to disable the link. - * - */ - link?: string; - }; - }; -} diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index ef9e23e16f..f3bdef761c 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -48,8 +48,6 @@ "supertest": "^4.0.2" }, "files": [ - "dist", - "config.d.ts" - ], - "configSchema": "config.d.ts" + "dist" + ] } diff --git a/plugins/badges-backend/src/badges.ts b/plugins/badges-backend/src/badges.ts index c830fbcfa2..da1dad6bce 100644 --- a/plugins/badges-backend/src/badges.ts +++ b/plugins/badges-backend/src/badges.ts @@ -16,7 +16,7 @@ import { Badge } from './types'; -export const badges: Badge[] = [ +export const createDefaultBadges = (): Badge[] => [ { id: 'pingback', kind: 'entity', diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index 76d2645b51..c9c9021ddd 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -17,29 +17,31 @@ import { Logger } from 'winston'; import { makeBadge } from 'badge-maker'; import { BadgeBuilder, BadgeOptions } from './types'; -import { Badge, BadgeConfig, BadgeStyle, BadgeStyles } from '../../types'; +import { Badge, BadgeConfig, BadgeStyle, BADGE_STYLES } from '../../types'; import { interpolate } from '../../utils'; export class DefaultBadgeBuilder implements BadgeBuilder { - constructor( - private readonly logger: Logger, - private readonly config: BadgeConfig, - ) { - for (const [badgeId, badge] of Object.entries(config)) { - if (badge) { - badge.id = badgeId; + private readonly badges: BadgeConfig = {}; + + 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 { - return Object.values(this.config); + return Object.values(this.badges); } public async getBadgeConfig(badgeId: string): Promise { return ( - this.config[badgeId] || - this.config.default || + this.badges[badgeId] || + this.badges.default || ({ label: 'Unknown badge ID', message: badgeId, @@ -60,7 +62,7 @@ export class DefaultBadgeBuilder implements BadgeBuilder { params.labelColor = badge.labelColor; } - if (BadgeStyles.includes(badge.style as BadgeStyle)) { + if (BADGE_STYLES.includes(badge.style as BadgeStyle)) { params.style = badge.style as BadgeStyle; } diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 694d53aed0..31eb96ceb1 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -25,7 +25,7 @@ import { import { Entity } from '@backstage/catalog-model'; import { Config, JsonObject } from '@backstage/config'; import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; -import { Badge, BadgeConfig, BadgeStyle, BadgeStyles } from '../types'; +import { Badge, BadgeStyle, BADGE_STYLES } from '../types'; export interface RouterOptions { badgeBuilder?: BadgeBuilder; @@ -42,22 +42,9 @@ export async function createRouter( const logger = options.logger.child({ plugin: 'badges' }); const title = options.config.getString('app.title') || 'Backstage'; const catalogUrl = `${options.config.getString('app.baseUrl')}/catalog`; - const badgesConfig = (options.config.getOptional('badges') ?? - {}) as BadgeConfig; - - for (const badge of options.badges || []) { - if (!badge.id) { - logger.warning(`badge without "id": ${JSON.stringify(badge, null, 2)}`); - } else if (!badgesConfig[badge.id]) { - badgesConfig[badge.id] = badge; - logger.info(`register builtin badge: ${badge.id}`); - } else { - logger.info(`builtin badge replaced from configuration: ${badge.id}`); - } - } - const badgeBuilder = - options.badgeBuilder || new DefaultBadgeBuilder(logger, badgesConfig); + options.badgeBuilder || + new DefaultBadgeBuilder(logger, options.badges || []); router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => { const entityUri = getEntityUri(req.params); @@ -122,7 +109,7 @@ export async function createRouter( format = 'application/json'; } - if (BadgeStyles.includes(req.query.style as BadgeStyle)) { + if (BADGE_STYLES.includes(req.query.style as BadgeStyle)) { badge.style = req.query.style as BadgeStyle; } diff --git a/plugins/badges-backend/src/types.ts b/plugins/badges-backend/src/types.ts index 00a3d40756..0943589422 100644 --- a/plugins/badges-backend/src/types.ts +++ b/plugins/badges-backend/src/types.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -export const BadgeStyles = [ +export const BADGE_STYLES = [ 'plastic', 'flat', 'flat-square', 'for-the-badge', 'social', ] as const; -export type BadgeStyle = typeof BadgeStyles[number]; +export type BadgeStyle = typeof BADGE_STYLES[number]; export interface Badge { /** Unique name for the badge. */ From ad95bebc8f1756487670cd9c5ec91f4457ee2668 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Tue, 2 Mar 2021 12:41:40 +0100 Subject: [PATCH 16/24] badges: avoid badge dialog content to scroll horizontally. Signed-off-by: Andreas Stenius --- plugins/badges/src/components/EntityBadgesDialog.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx index 4fac612ec4..f30278ca4d 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -40,6 +40,9 @@ type Props = { }; const useStyles = makeStyles({ + content: { + overflowX: 'hidden', + }, codeBlock: { '& code': { whiteSpace: 'pre-wrap', @@ -86,7 +89,7 @@ export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { Entity Badges - + {loading ? : null} {error ? ( From 5a09c681d7edc48c63aa5a8de2ebd4995f579c5c Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Thu, 4 Mar 2021 14:05:31 +0100 Subject: [PATCH 17/24] badges: refactoring to use badge factories to create dynamic badges. Signed-off-by: Andreas Stenius --- packages/backend/src/plugins/badges.ts | 6 +- plugins/badges-backend/src/badges.ts | 108 +++++++++++++----- .../lib/BadgeBuilder/DefaultBadgeBuilder.ts | 96 +++++----------- .../src/lib/BadgeBuilder/types.ts | 17 +-- .../badges-backend/src/service/router.test.ts | 6 +- plugins/badges-backend/src/service/router.ts | 81 +++++-------- .../src/service/standaloneServer.ts | 2 +- plugins/badges-backend/src/types.ts | 23 ++-- plugins/badges/src/api/types.ts | 29 ++--- .../src/components/EntityBadgesDialog.tsx | 10 +- 10 files changed, 171 insertions(+), 207 deletions(-) diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts index cea58d8737..befca76542 100644 --- a/packages/backend/src/plugins/badges.ts +++ b/packages/backend/src/plugins/badges.ts @@ -16,19 +16,17 @@ import { createRouter, - createDefaultBadges, + createDefaultBadgeFactories, } from '@backstage/plugin-badges-backend'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ - logger, config, discovery, }: PluginEnvironment) { return await createRouter({ - logger, config, discovery, - badges: createDefaultBadges(), + badgeFactories: createDefaultBadgeFactories(), }); } diff --git a/plugins/badges-backend/src/badges.ts b/plugins/badges-backend/src/badges.ts index da1dad6bce..03e32814fd 100644 --- a/plugins/badges-backend/src/badges.ts +++ b/plugins/badges-backend/src/badges.ts @@ -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', + }; + }, }, -]; +}); diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index c9c9021ddd..ce905d8e9d 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -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 { - return Object.values(this.badges); - } - - public async getBadgeConfig(badgeId: string): Promise { - return ( - this.badges[badgeId] || - this.badges.default || - ({ - label: 'Unknown badge ID', - message: badgeId, - color: 'red', - } as Badge) - ); + public async getBadgeIds(): Promise { + return Object.keys(this.factories); } public async createBadge(options: BadgeOptions): Promise { - 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}"` : ''; diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts index e6cb3213e9..9c17d8e3c3 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -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; - getBadgeConfig(badgeId: string): Promise; - getAllBadgeConfigs(): Promise; + getBadgeIds(): Promise; }; diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 90d91eb6f3..72400b50b5 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -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(); }); }); diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 31eb96ceb1..0289f39148 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -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 { 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()); diff --git a/plugins/badges-backend/src/service/standaloneServer.ts b/plugins/badges-backend/src/service/standaloneServer.ts index d358edcbdc..c210efa249 100644 --- a/plugins/badges-backend/src/service/standaloneServer.ts +++ b/plugins/badges-backend/src/service/standaloneServer.ts @@ -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' }) diff --git a/plugins/badges-backend/src/types.ts b/plugins/badges-backend/src/types.ts index 0943589422..ee4876b90e 100644 --- a/plugins/badges-backend/src/types.ts +++ b/plugins/badges-backend/src/types.ts @@ -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; } diff --git a/plugins/badges/src/api/types.ts b/plugins/badges/src/api/types.ts index d3ba234e44..9765af2c93 100644 --- a/plugins/badges/src/api/types.ts +++ b/plugins/badges/src/api/types.ts @@ -29,7 +29,7 @@ export type BadgeStyle = | 'for-the-badge' | 'social'; -interface BadgeParams { +interface Badge { color?: string; description?: string; kind?: 'entity'; @@ -40,29 +40,18 @@ interface BadgeParams { style?: BadgeStyle; } -interface Badge extends BadgeParams { - markdown: string; -} - -interface BadgeConfig extends BadgeParams { - id: string; -} - export interface BadgeSpec { - /** The rendered fields, markdown code */ + /** Badge id */ + id: string; + + /** Badge data */ badge: Badge; - /** The configuration data, with placeholders and all */ - config: BadgeConfig; + /** The URL to the badge image */ + url: string; - /** The context used when rendering config -> badge */ - context: { - // here is more, but only badge_url we care about - badge_url: string; - }; - - format: 'json'; // or 'svg', but we'll never see that as structured - // data, only as an svg element + /** The markdown code to use the badge */ + markdown: string; } export interface BadgesApi { diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx index f30278ca4d..9d4661f520 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -65,16 +65,12 @@ export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { }, [badgesApi, entity, open]); const content = (badges || []).map( - ({ - badge: { description, markdown }, - config: { id }, - context: { badge_url }, - }) => ( + ({ badge: { description }, id, url, markdown }) => (
- {description} + {description || `${id} badge`}
- {description} + {description
Copy the following snippet of markdown code for the badge: From c6b24adc2978b093fca4395adb25604f4e897840 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Tue, 9 Mar 2021 09:48:29 +0100 Subject: [PATCH 18/24] badges: bump versions. Signed-off-by: Andreas Stenius --- plugins/badges-backend/package.json | 8 +-- plugins/badges/package.json | 16 +++--- .../EntityPageLayout/EntityPageLayout.tsx | 4 +- yarn.lock | 50 ++++++++++++++++++- 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index f3bdef761c..470e718b3f 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.2", - "@backstage/catalog-model": "^0.7.1", - "@backstage/config": "^0.1.2", + "@backstage/backend-common": "^0.5.5", + "@backstage/catalog-model": "^0.7.3", + "@backstage/config": "^0.1.3", "@types/express": "^4.17.6", "badge-maker": "^3.3.0", "cors": "^2.8.5", @@ -43,7 +43,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.0", + "@backstage/cli": "^0.6.3", "@types/supertest": "^2.0.8", "supertest": "^4.0.2" }, diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 65e09e2524..afc930aacf 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.1", - "@backstage/core": "^0.6.1", - "@backstage/plugin-catalog-react": "^0.0.3", + "@backstage/catalog-model": "^0.7.3", + "@backstage/core": "^0.7.0", + "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -33,14 +33,14 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.0", - "@backstage/dev-utils": "^0.1.10", - "@backstage/test-utils": "^0.1.7", + "@backstage/cli": "^0.6.3", + "@backstage/dev-utils": "^0.1.13", + "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^10.4.1", + "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", - "@types/node": "^12.0.0", + "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", "msw": "^0.21.2" }, diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index 663825b409..64a51f2bb8 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -132,7 +132,8 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { setBadgesDialogOpen(true)} - onUnregisterEntity={showRemovalDialog} /> + onUnregisterEntity={showRemovalDialog} + /> )}
@@ -170,6 +171,7 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { onClose={() => setBadgesDialogOpen(false)} /> )} + Date: Wed, 10 Mar 2021 12:28:26 +0100 Subject: [PATCH 19/24] badges: fix SVG spelling in readme. Signed-off-by: Andreas Stenius --- plugins/badges-backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index 94008c4ba0..abe8884600 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -2,7 +2,7 @@ Backend plugin for serving badges. Default implementation uses [badge-maker](https://www.npmjs.com/package/badge-maker) for creating -the badges, in svg. +the badges, in SVG. ## Setup From 19f4048e758cc60df74f57e9fbd12a5cdcc8c875 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Thu, 11 Mar 2021 13:53:00 +0100 Subject: [PATCH 20/24] badges-backend: add router and api tests. Signed-off-by: Andreas Stenius --- plugins/badges-backend/README.md | 41 ++++- plugins/badges-backend/package.json | 1 + plugins/badges-backend/src/index.ts | 4 +- .../BadgeBuilder/DefaultBadgeBuilder.test.ts | 123 ++++++++++++++ .../src/{utils.ts => lib/index.ts} | 10 +- .../badges-backend/src/service/router.test.ts | 151 ++++++++++++++++-- plugins/badges-backend/src/service/router.ts | 43 ++--- plugins/badges/README.md | 9 +- 8 files changed, 326 insertions(+), 56 deletions(-) create mode 100644 plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts rename plugins/badges-backend/src/{utils.ts => lib/index.ts} (64%) diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index abe8884600..22354cb444 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -1,13 +1,46 @@ # Badges Backend Backend plugin for serving badges. Default implementation uses -[badge-maker](https://www.npmjs.com/package/badge-maker) for creating -the badges, in SVG. +[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 a entity specific +endpoint. ## Setup -The list of all badges to offer are passed to the badges-backend -`createRouter()`. +The list of all badges to offer are passed as an object with badge factories to +the badges-backend `createRouter()` during plugin registration. + +## 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. + +## 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 diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 470e718b3f..5c395bf3ea 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@backstage/backend-common": "^0.5.5", + "@backstage/catalog-client": "^0.3.6", "@backstage/catalog-model": "^0.7.3", "@backstage/config": "^0.1.3", "@types/express": "^4.17.6", diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts index dc08144706..240bce56af 100644 --- a/plugins/badges-backend/src/index.ts +++ b/plugins/badges-backend/src/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ +export * from './badges'; +export * from './lib'; export * from './service/router'; export * from './types'; -export * from './utils'; -export * from './badges'; 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..9fc9bcf32d --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts @@ -0,0 +1,123 @@ +/* + * 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 { DefaultBadgeBuilder } from './DefaultBadgeBuilder'; +import { BadgeBuilder, BadgeOptions } from './types'; +import { BadgeContext, BadgeFactories } from '../../types'; + +describe('DefaultBadgeBuilder', () => { + let builder: BadgeBuilder; + let config: jest.Mocked; + let factories: BadgeFactories; + + const badge = { + description: 'a test badge', + label: 'test', + message: 'ok', + link: 'http://example.com/badgelink', + }; + + beforeAll(() => { + config = { + get: jest.fn(), + getBoolean: jest.fn(), + getConfig: jest.fn(), + getConfigArray: jest.fn(), + getNumber: jest.fn(), + getOptional: jest.fn(), + getOptionalBoolean: jest.fn(), + getOptionalConfig: jest.fn(), + getOptionalConfigArray: jest.fn(), + getOptionalNumber: jest.fn(), + getOptionalString: jest.fn(), + getOptionalStringArray: jest.fn(), + getString: jest.fn(), + getStringArray: jest.fn(), + has: jest.fn(), + keys: jest.fn(), + }; + + factories = { + testbadge: { + createBadge: () => badge, + }, + }; + }); + + beforeEach(() => { + jest.resetAllMocks(); + builder = new DefaultBadgeBuilder(factories); + }); + + it('getBadgeIds() returns all badge factory ids', async () => { + expect(await builder.getBadgeIds()).toEqual(['testbadge']); + }); + + describe('createBadge', () => { + const context: BadgeContext = { + badgeUrl: 'http://127.0.0.1/badge/url', + config, + }; + + it('returns the spec when format is "json"', async () => { + const options: BadgeOptions = { + badgeId: 'testbadge', + context, + format: 'json', + }; + + const spec = await builder.createBadge(options); + expect(JSON.parse(spec)).toEqual({ + badge, + id: 'testbadge', + url: context.badgeUrl, + markdown: `[![a test badge, test: ok](${context.badgeUrl} "a test badge")](${badge.link})`, + }); + }); + + it('returns the badge image when format is "svg"', async () => { + const options: BadgeOptions = { + badgeId: 'testbadge', + context, + format: 'svg', + }; + + const spec = await builder.createBadge(options); + expect(spec).toEqual(expect.stringMatching(/^]*>.*<\/svg>$/)); + }); + + it('returns "unknown" badge for missing factory', async () => { + const options: BadgeOptions = { + badgeId: 'other-id', + context, + format: 'json', + }; + + const spec = await builder.createBadge(options); + expect(JSON.parse(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/utils.ts b/plugins/badges-backend/src/lib/index.ts similarity index 64% rename from plugins/badges-backend/src/utils.ts rename to plugins/badges-backend/src/lib/index.ts index 0f4773170e..47cccec8f8 100644 --- a/plugins/badges-backend/src/utils.ts +++ b/plugins/badges-backend/src/lib/index.ts @@ -14,12 +14,4 @@ * limitations under the License. */ -/** - * adapted from https://stackoverflow.com/a/41015840/444060 - */ -export function interpolate(template: string, params: object): string { - const names = Object.keys(params); - const vals = Object.values(params); - // eslint-disable-next-line no-new-func - return new Function(...names, `return \`${template}\`;`)(...vals); -} +export * from './BadgeBuilder'; diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 72400b50b5..c05a3c9c58 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -14,19 +14,152 @@ * limitations under the License. */ -import * as winston from 'winston'; -import { - loadBackendConfig, - SingleHostDiscovery, -} from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; +import { CatalogApi } from '@backstage/catalog-client'; +import type { Entity } from '@backstage/catalog-model'; +import { Config } 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: jest.Mocked; + + const entity: Entity = { + apiVersion: 'v1', + kind: 'service', + metadata: { + name: 'test', + }, + }; + + const badge = { + id: 'test-badge', + badge: { label: 'test badge' }, + url: '/...', + markdown: '[![...](...)]', + }; + + beforeAll(async () => { + badgeBuilder = { + createBadge: jest.fn(), + getBadgeIds: jest.fn(), + }; + catalog = { + addLocation: jest.fn(), + getEntities: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + }; + config = { + get: jest.fn(), + getBoolean: jest.fn(), + getConfig: jest.fn(), + getConfigArray: jest.fn(), + getNumber: jest.fn(), + getOptional: jest.fn(), + getOptionalBoolean: jest.fn(), + getOptionalConfig: jest.fn(), + getOptionalConfigArray: jest.fn(), + getOptionalNumber: jest.fn(), + getOptionalString: jest.fn(), + getOptionalStringArray: jest.fn(), + getString: jest.fn(), + getStringArray: jest.fn(), + has: jest.fn(), + keys: jest.fn(), + }; + const router = await createRouter({ badgeBuilder, catalog, config }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + it('works', async () => { - const logger = winston.createLogger(); - const config = await loadBackendConfig({ logger, argv: [] }); - const discovery = SingleHostDiscovery.fromConfig(config); - const router = await createRouter({ config, discovery }); + const router = await createRouter({ badgeBuilder, catalog, config }); expect(router).toBeDefined(); }); + + describe('GET /entity/:namespace/:kind/:name/badge-specs', () => { + it('returns all badge specs for entity', async () => { + catalog.getEntityByName.mockResolvedValueOnce(entity); + + badgeBuilder.getBadgeIds.mockResolvedValueOnce([badge.id]); + badgeBuilder.createBadge.mockResolvedValueOnce( + JSON.stringify(badge, null, 2), + ); + + 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.getBadgeIds).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadge).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadge).toHaveBeenCalledWith({ + badgeId: badge.id, + context: { + badgeUrl: expect.stringMatching( + /http:\/\/127.0.0.1:\d+\/entity\/default\/service\/test\/test-badge/, + ), + config, + entity, + }, + format: 'json', + }); + }); + }); + + describe('GET /entity/:namespace/:kind/:name/test-badge', () => { + it('returns badge for entity', async () => { + catalog.getEntityByName.mockResolvedValueOnce(entity); + + const image = '...'; + badgeBuilder.createBadge.mockResolvedValueOnce(image); + + const response = await request(app).get( + '/entity/default/service/test/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.getBadgeIds).toHaveBeenCalledTimes(0); + expect(badgeBuilder.createBadge).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadge).toHaveBeenCalledWith({ + badgeId: badge.id, + context: { + badgeUrl: expect.stringMatching( + /http:\/\/127.0.0.1:\d+\/entity\/default\/service\/test\/test-badge/, + ), + config, + entity, + }, + format: 'svg', + }); + }); + }); }); diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 0289f39148..86caf793d5 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -15,35 +15,40 @@ */ import express from 'express'; -import fetch from 'cross-fetch'; import Router from 'express-promise-router'; import { errorHandler, PluginEndpointDiscovery, } from '@backstage/backend-common'; -import { Entity } from '@backstage/catalog-model'; -import { Config, JsonObject } from '@backstage/config'; +import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; import { BadgeContext, BadgeFactories } from '../types'; export interface RouterOptions { badgeBuilder?: BadgeBuilder; badgeFactories?: BadgeFactories; + catalog?: CatalogApi; config: Config; - discovery: PluginEndpointDiscovery; + discovery?: PluginEndpointDiscovery; } export async function createRouter( options: RouterOptions, ): Promise { - const router = Router(); + if (!options.catalog && !options.discovery) { + throw new Error('must provide either catalog api or discovery api'); + } + 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 entityUri = getEntityUri(req.params); - const entity = await getEntity(options.discovery, entityUri); + const { namespace, kind, name } = req.params; + const entity = await catalog.getEntityByName({ namespace, kind, name }); if (!entity) { res.status(404).send(`Unknown entity`); return; @@ -78,10 +83,8 @@ export async function createRouter( }); router.get('/entity/:namespace/:kind/:name/:badgeId', async (req, res) => { - const { badgeId } = req.params; - - const entityUri = getEntityUri(req.params); - const entity = await getEntity(options.discovery, entityUri); + const { namespace, kind, name, badgeId } = req.params; + const entity = await catalog.getEntityByName({ namespace, kind, name }); if (!entity) { res.status(404).send(`Unknown entity`); return; @@ -117,21 +120,3 @@ export async function createRouter( return router; } - -function getEntityUri(params: JsonObject): string { - const { kind, namespace, name } = params; - return `${kind}/${namespace}/${name}`; -} - -async function getEntity( - discovery: PluginEndpointDiscovery, - entityUri: string, -): Promise { - const catalogUrl = await discovery.getBaseUrl('catalog'); - - const entity = (await ( - await fetch(`${catalogUrl}/entities/by-name/${entityUri}`) - ).json()) as Entity; - - return entity; -} diff --git a/plugins/badges/README.md b/plugins/badges/README.md index 221d3a0e90..09e00c79fe 100644 --- a/plugins/badges/README.md +++ b/plugins/badges/README.md @@ -5,10 +5,13 @@ 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. +link below for more details. -To get markdown code for the badges, access the `Badges` context menu -(three dots in the upper right corner) of an entity page. +## 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 From 022610b7b71bb0b4fe6a8f14cfbb2a3db2954aba Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Thu, 11 Mar 2021 15:29:22 +0100 Subject: [PATCH 21/24] badges: bump versions. Signed-off-by: Andreas Stenius --- plugins/badges/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/badges/package.json b/plugins/badges/package.json index afc930aacf..51aae31621 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.0", + "@backstage/core": "^0.7.1", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.3", + "@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", @@ -33,7 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.3", + "@backstage/cli": "^0.6.4", "@backstage/dev-utils": "^0.1.13", "@backstage/test-utils": "^0.1.8", "@testing-library/jest-dom": "^5.10.1", From 97836e51f189434a18b366cfe7dcd8932ee48a75 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Fri, 12 Mar 2021 09:00:03 +0100 Subject: [PATCH 22/24] badges: use new error api. revert css layout tweak for CodeSnippet. add basic frontend test. improve doc. Signed-off-by: Andreas Stenius --- plugins/badges-backend/README.md | 58 ++++++++++++++++-- plugins/badges-backend/package.json | 1 + plugins/badges-backend/src/service/router.ts | 19 +++--- plugins/badges/README.md | 2 +- .../components/EntityBadgesDialog.test.tsx | 61 +++++++++++++++++++ .../src/components/EntityBadgesDialog.tsx | 7 +-- 6 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 plugins/badges/src/components/EntityBadgesDialog.test.tsx diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index 22354cb444..59f5fc0445 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -1,6 +1,7 @@ # Badges Backend -Backend plugin for serving badges. Default implementation uses +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. @@ -8,10 +9,35 @@ Currently, only entity badges are implemented. i.e. badges that may have entity specific information in them, and as such, are served from a entity specific endpoint. -## Setup +## Installation -The list of all badges to offer are passed as an object with badge factories to -the badges-backend `createRouter()` during plugin registration. +Install the `@backstage/plugin-badges-backend` package in your backend packages, +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 @@ -28,6 +54,30 @@ 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 badges 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 diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 5c395bf3ea..fa6f6a4557 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -34,6 +34,7 @@ "@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", diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 86caf793d5..797d23f432 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -22,6 +22,7 @@ import { } 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'; @@ -50,8 +51,9 @@ export async function createRouter( const { namespace, kind, name } = req.params; const entity = await catalog.getEntityByName({ namespace, kind, name }); if (!entity) { - res.status(404).send(`Unknown entity`); - return; + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + ); } const context: BadgeContext = { @@ -86,8 +88,9 @@ export async function createRouter( const { namespace, kind, name, badgeId } = req.params; const entity = await catalog.getEntityByName({ namespace, kind, name }); if (!entity) { - res.status(404).send(`Unknown entity`); - return; + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + ); } let format = @@ -109,11 +112,11 @@ export async function createRouter( }); if (!data) { - res.status(404).send(`Unknown entity badge "${badgeId}"`); - } else { - res.setHeader('Content-Type', format); - res.status(200).send(data); + throw new NotFoundError(`Unknown badge "${badgeId}" for ${kind} entity.`); } + + res.setHeader('Content-Type', format); + res.status(200).send(data); }); router.use(errorHandler()); diff --git a/plugins/badges/README.md b/plugins/badges/README.md index 09e00c79fe..bfc01ec63c 100644 --- a/plugins/badges/README.md +++ b/plugins/badges/README.md @@ -1,4 +1,4 @@ -# Badges +# @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 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 index 9d4661f520..ac0d46f114 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -35,14 +35,11 @@ import { badgesApiRef } from '../api'; type Props = { open: boolean; - onClose: () => any; + onClose?: () => any; entity: Entity; }; const useStyles = makeStyles({ - content: { - overflowX: 'hidden', - }, codeBlock: { '& code': { whiteSpace: 'pre-wrap', @@ -85,7 +82,7 @@ export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { Entity Badges - + {loading ? : null} {error ? ( From c46ab4afbae18a5e3d57b0c55fd8cf1fc9661e1c Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Tue, 16 Mar 2021 15:12:14 +0100 Subject: [PATCH 23/24] badges: refactoring after review from @freben. Signed-off-by: Andreas Stenius --- .changeset/red-peas-smoke.md | 6 - plugins/badges-backend/README.md | 10 +- plugins/badges-backend/src/badges.ts | 20 +-- .../BadgeBuilder/DefaultBadgeBuilder.test.ts | 56 +++------ .../lib/BadgeBuilder/DefaultBadgeBuilder.ts | 81 ++++++------ .../src/lib/BadgeBuilder/index.ts | 2 +- .../src/lib/BadgeBuilder/types.ts | 28 ++++- .../badges-backend/src/service/router.test.ts | 96 +++++++------- plugins/badges-backend/src/service/router.ts | 117 ++++++++++-------- plugins/badges-backend/src/types.ts | 2 +- plugins/badges/package.json | 1 + plugins/badges/src/api/BadgesClient.ts | 11 +- .../src/components/EntityBadgesDialog.tsx | 20 ++- 13 files changed, 231 insertions(+), 219 deletions(-) delete mode 100644 .changeset/red-peas-smoke.md diff --git a/.changeset/red-peas-smoke.md b/.changeset/red-peas-smoke.md deleted file mode 100644 index 4640e12b48..0000000000 --- a/.changeset/red-peas-smoke.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-badges': patch -'@backstage/plugin-badges-backend': patch ---- - -New plugin to serve badges. diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index 59f5fc0445..e64360656d 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -6,12 +6,12 @@ Default implementation uses 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 a entity specific +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 packages, +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`: @@ -56,7 +56,7 @@ factories, and provide them to the badge builder. ### Custom badges -To provide custom badges, create a badges factories function, and use that when +To provide custom badges, create a badge factories function, and use that when creating the badges backend router. ```ts @@ -80,8 +80,8 @@ export const createMyCustomBadgeFactories = (): BadgeFactories => ({ ## API -The badges backend api exposes two main endpoints for entity badges. (the -`/badges` prefix is arbitrary, and the default for the example backend.) +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 diff --git a/plugins/badges-backend/src/badges.ts b/plugins/badges-backend/src/badges.ts index 03e32814fd..2e2848a8af 100644 --- a/plugins/badges-backend/src/badges.ts +++ b/plugins/badges-backend/src/badges.ts @@ -15,6 +15,7 @@ */ import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; import { Badge, BadgeContext, BadgeFactories } from './types'; function appTitle(context: BadgeContext): string { @@ -27,14 +28,14 @@ function entityUrl(context: BadgeContext): string { e.metadata.namespace || ENTITY_DEFAULT_NAMESPACE }/${e.metadata.name}`; const catalogUrl = `${context.config.getString('app.baseUrl')}/catalog`; - return `${catalogUrl}/${entityUri}`; + return `${catalogUrl}/${entityUri}`.toLowerCase(); } export const createDefaultBadgeFactories = (): BadgeFactories => ({ pingback: { - createBadge: (context: BadgeContext): Badge | null => { + createBadge: (context: BadgeContext): Badge => { if (!context.entity) { - return null; + throw new InputError('"pingback" badge only defined for entities'); } return { description: `Link to ${context.entity.metadata.name} in ${appTitle( @@ -50,9 +51,9 @@ export const createDefaultBadgeFactories = (): BadgeFactories => ({ }, lifecycle: { - createBadge: (context: BadgeContext): Badge | null => { + createBadge: (context: BadgeContext): Badge => { if (!context.entity) { - return null; + throw new InputError('"lifecycle" badge only defined for entities'); } return { description: 'Entity lifecycle badge', @@ -66,9 +67,9 @@ export const createDefaultBadgeFactories = (): BadgeFactories => ({ }, owner: { - createBadge: (context: BadgeContext): Badge | null => { + createBadge: (context: BadgeContext): Badge => { if (!context.entity) { - return null; + throw new InputError('"owner" badge only defined for entities'); } return { description: 'Entity owner badge', @@ -82,11 +83,12 @@ export const createDefaultBadgeFactories = (): BadgeFactories => ({ }, docs: { - createBadge: (context: BadgeContext): Badge | null => { + createBadge: (context: BadgeContext): Badge => { if (!context.entity) { - return null; + throw new InputError('"docs" badge only defined for entities'); } return { + description: 'Entity docs badge', kind: 'entity', label: 'docs', link: `${entityUrl(context)}/docs`, diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts index 9fc9bcf32d..a36587f241 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; +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: jest.Mocked; + let config: Config; let factories: BadgeFactories; const badge = { @@ -32,24 +32,9 @@ describe('DefaultBadgeBuilder', () => { }; beforeAll(() => { - config = { - get: jest.fn(), - getBoolean: jest.fn(), - getConfig: jest.fn(), - getConfigArray: jest.fn(), - getNumber: jest.fn(), - getOptional: jest.fn(), - getOptionalBoolean: jest.fn(), - getOptionalConfig: jest.fn(), - getOptionalConfigArray: jest.fn(), - getOptionalNumber: jest.fn(), - getOptionalString: jest.fn(), - getOptionalStringArray: jest.fn(), - getString: jest.fn(), - getStringArray: jest.fn(), - has: jest.fn(), - keys: jest.fn(), - }; + config = new ConfigReader({ + backend: { baseUrl: 'http://127.0.0.1' }, + }); factories = { testbadge: { @@ -63,25 +48,24 @@ describe('DefaultBadgeBuilder', () => { builder = new DefaultBadgeBuilder(factories); }); - it('getBadgeIds() returns all badge factory ids', async () => { - expect(await builder.getBadgeIds()).toEqual(['testbadge']); + it('getBadges() returns all badge factory ids', async () => { + expect(await builder.getBadges()).toEqual([{ id: 'testbadge' }]); }); - describe('createBadge', () => { + describe('createBadge[Json|Svg]', () => { const context: BadgeContext = { badgeUrl: 'http://127.0.0.1/badge/url', config, }; - it('returns the spec when format is "json"', async () => { + it('badge spec', async () => { const options: BadgeOptions = { - badgeId: 'testbadge', + badgeInfo: { id: 'testbadge' }, context, - format: 'json', }; - const spec = await builder.createBadge(options); - expect(JSON.parse(spec)).toEqual({ + const spec = await builder.createBadgeJson(options); + expect(spec).toEqual({ badge, id: 'testbadge', url: context.badgeUrl, @@ -89,26 +73,24 @@ describe('DefaultBadgeBuilder', () => { }); }); - it('returns the badge image when format is "svg"', async () => { + it('badge image', async () => { const options: BadgeOptions = { - badgeId: 'testbadge', + badgeInfo: { id: 'testbadge' }, context, - format: 'svg', }; - const spec = await builder.createBadge(options); - expect(spec).toEqual(expect.stringMatching(/^]*>.*<\/svg>$/)); + const img = await builder.createBadgeSvg(options); + expect(img).toEqual(expect.stringMatching(/^]*>.*<\/svg>$/)); }); it('returns "unknown" badge for missing factory', async () => { const options: BadgeOptions = { - badgeId: 'other-id', + badgeInfo: { id: 'other-id' }, context, - format: 'json', }; - const spec = await builder.createBadge(options); - expect(JSON.parse(spec)).toEqual({ + const spec = await builder.createBadgeJson(options); + expect(spec).toEqual({ badge: { label: 'unknown badge', message: 'other-id', diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index ce905d8e9d..42823ebd09 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -14,72 +14,69 @@ * limitations under the License. */ +import { InputError } from '@backstage/errors'; import { makeBadge, Format } from 'badge-maker'; -import { BadgeBuilder, BadgeOptions } from './types'; +import { BadgeBuilder, BadgeInfo, BadgeOptions, BadgeSpec } from './types'; import { Badge, BadgeFactories } from '../../types'; export class DefaultBadgeBuilder implements BadgeBuilder { constructor(private readonly factories: BadgeFactories) {} - public async getBadgeIds(): Promise { - return Object.keys(this.factories); + public async getBadges(): Promise { + return Object.keys(this.factories).map(id => ({ id })); } - public async createBadge(options: BadgeOptions): Promise { - const factory = this.factories[options.badgeId]; + 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.badgeId, + message: options.badgeInfo.id, color: 'red', } as Badge); if (!badge) { - return ''; + throw new InputError( + `The badge factory failed to produce a "${options.badgeInfo.id}" badge with the provided context`, + ); } - switch (options.format) { - case 'json': - return JSON.stringify( - { - badge, - id: options.badgeId, - url: options.context.badgeUrl, - markdown: this.getMarkdownCode(badge, options.context.badgeUrl), - }, - null, - 2, - ); - case 'svg': - 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', - }); - } - default: - throw new TypeError(`unsupported badge format: ${options.format}`); + 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, badge_url: string): string { - let alt_text = `${params.label}: ${params.message}`; + private getMarkdownCode(params: Badge, badgeUrl: string): string { + let altText = `${params.label}: ${params.message}`; if (params.description && params.description !== params.label) { - alt_text = `${params.description}, ${alt_text}`; + altText = `${params.description}, ${altText}`; } const tooltip = params.description ? ` "${params.description}"` : ''; - const img = `![${alt_text}](${badge_url}${tooltip})`; + 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 index 20f18b8852..4947db96e2 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/index.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/index.ts @@ -15,4 +15,4 @@ */ export { DefaultBadgeBuilder } from './DefaultBadgeBuilder'; -export * from './types'; +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 index 9c17d8e3c3..fcd1cd4c5b 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -14,15 +14,33 @@ * limitations under the License. */ -import { BadgeContext } from '../../types'; +import { Badge, BadgeContext } from '../../types'; + +export type BadgeInfo = { + id: string; +}; export type BadgeOptions = { - badgeId: string; + badgeInfo: BadgeInfo; context: BadgeContext; - format: 'svg' | 'json'; +}; + +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 = { - createBadge(options: BadgeOptions): Promise; - getBadgeIds(): Promise; + getBadges(): Promise; + createBadgeJson(options: BadgeOptions): Promise; + createBadgeSvg(options: BadgeOptions): Promise; }; diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index c05a3c9c58..e8de5dfd42 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -16,9 +16,13 @@ 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 } from '@backstage/config'; +import { Config, ConfigReader } from '@backstage/config'; import { createRouter } from './router'; import { BadgeBuilder } from '../lib'; @@ -26,7 +30,8 @@ describe('createRouter', () => { let app: express.Express; let badgeBuilder: jest.Mocked; let catalog: jest.Mocked; - let config: jest.Mocked; + let config: Config; + let discovery: PluginEndpointDiscovery; const entity: Entity = { apiVersion: 'v1', @@ -38,15 +43,19 @@ describe('createRouter', () => { const badge = { id: 'test-badge', - badge: { label: 'test badge' }, + badge: { + label: 'test', + message: 'badge', + }, url: '/...', markdown: '[![...](...)]', }; beforeAll(async () => { badgeBuilder = { - createBadge: jest.fn(), - getBadgeIds: jest.fn(), + getBadges: jest.fn(), + createBadgeJson: jest.fn(), + createBadgeSvg: jest.fn(), }; catalog = { addLocation: jest.fn(), @@ -56,25 +65,21 @@ describe('createRouter', () => { getLocationById: jest.fn(), removeEntityByUid: jest.fn(), }; - config = { - get: jest.fn(), - getBoolean: jest.fn(), - getConfig: jest.fn(), - getConfigArray: jest.fn(), - getNumber: jest.fn(), - getOptional: jest.fn(), - getOptionalBoolean: jest.fn(), - getOptionalConfig: jest.fn(), - getOptionalConfigArray: jest.fn(), - getOptionalNumber: jest.fn(), - getOptionalString: jest.fn(), - getOptionalStringArray: jest.fn(), - getString: jest.fn(), - getStringArray: jest.fn(), - has: jest.fn(), - keys: jest.fn(), - }; - const router = await createRouter({ badgeBuilder, catalog, config }); + + 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); }); @@ -83,7 +88,12 @@ describe('createRouter', () => { }); it('works', async () => { - const router = await createRouter({ badgeBuilder, catalog, config }); + const router = await createRouter({ + badgeBuilder, + catalog, + config, + discovery, + }); expect(router).toBeDefined(); }); @@ -91,17 +101,15 @@ describe('createRouter', () => { it('returns all badge specs for entity', async () => { catalog.getEntityByName.mockResolvedValueOnce(entity); - badgeBuilder.getBadgeIds.mockResolvedValueOnce([badge.id]); - badgeBuilder.createBadge.mockResolvedValueOnce( - JSON.stringify(badge, null, 2), - ); + 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(response.text).toEqual(JSON.stringify([badge], null, 2)); expect(catalog.getEntityByName).toHaveBeenCalledTimes(1); expect(catalog.getEntityByName).toHaveBeenCalledWith({ @@ -110,31 +118,30 @@ describe('createRouter', () => { name: 'test', }); - expect(badgeBuilder.getBadgeIds).toHaveBeenCalledTimes(1); - expect(badgeBuilder.createBadge).toHaveBeenCalledTimes(1); - expect(badgeBuilder.createBadge).toHaveBeenCalledWith({ - badgeId: badge.id, + 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:\d+\/entity\/default\/service\/test\/test-badge/, + /http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/, ), config, entity, }, - format: 'json', }); }); }); - describe('GET /entity/:namespace/:kind/:name/test-badge', () => { + describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => { it('returns badge for entity', async () => { catalog.getEntityByName.mockResolvedValueOnce(entity); const image = '...'; - badgeBuilder.createBadge.mockResolvedValueOnce(image); + badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image); const response = await request(app).get( - '/entity/default/service/test/test-badge', + '/entity/default/service/test/badge/test-badge', ); expect(response.status).toEqual(200); @@ -147,18 +154,17 @@ describe('createRouter', () => { name: 'test', }); - expect(badgeBuilder.getBadgeIds).toHaveBeenCalledTimes(0); - expect(badgeBuilder.createBadge).toHaveBeenCalledTimes(1); - expect(badgeBuilder.createBadge).toHaveBeenCalledWith({ - badgeId: badge.id, + 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:\d+\/entity\/default\/service\/test\/test-badge/, + /http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/, ), config, entity, }, - format: 'svg', }); }); }); diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 797d23f432..bfbb10fc94 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -31,17 +31,14 @@ export interface RouterOptions { badgeFactories?: BadgeFactories; catalog?: CatalogApi; config: Config; - discovery?: PluginEndpointDiscovery; + discovery: PluginEndpointDiscovery; } export async function createRouter( options: RouterOptions, ): Promise { - if (!options.catalog && !options.discovery) { - throw new Error('must provide either catalog api or discovery api'); - } const catalog = - options.catalog || new CatalogClient({ discoveryApi: options.discovery! }); + options.catalog || new CatalogClient({ discoveryApi: options.discovery }); const badgeBuilder = options.badgeBuilder || new DefaultBadgeBuilder(options.badgeFactories || {}); @@ -56,70 +53,84 @@ export async function createRouter( ); } - const context: BadgeContext = { - badgeUrl: '', - config: options.config, - entity, - }; - const specs = []; - 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', - }); + 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(`[${specs.join(',\n')}]`); + res.status(200).send(JSON.stringify(specs, null, 2)); }); - router.get('/entity/:namespace/:kind/:name/: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}"`, - ); - } + 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'; - } + let format = + req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml'; + if (req.query.format === 'json') { + format = 'application/json'; + } - const badgeUrl = [ - `${req.protocol}://`, - req.headers.host, - req.originalUrl, - ].join(''); + const badgeOptions = { + badgeInfo: { id: badgeId }, + context: { + badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId, options), + config: options.config, + entity, + }, + }; - const data = await badgeBuilder.createBadge({ - badgeId, - context: { badgeUrl, config: options.config, entity }, - format: format === 'application/json' ? 'json' : 'svg', - }); + let data: string; + if (format === 'application/json') { + data = JSON.stringify( + await badgeBuilder.createBadgeJson(badgeOptions), + null, + 2, + ); + } else { + data = await badgeBuilder.createBadgeSvg(badgeOptions); + } - if (!data) { - throw new NotFoundError(`Unknown badge "${badgeId}" for ${kind} entity.`); - } - - res.setHeader('Content-Type', format); - res.status(200).send(data); - }); + 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/types.ts b/plugins/badges-backend/src/types.ts index ee4876b90e..69fc275f3f 100644 --- a/plugins/badges-backend/src/types.ts +++ b/plugins/badges-backend/src/types.ts @@ -55,7 +55,7 @@ export interface BadgeContext { } export interface BadgeFactory { - createBadge(context: BadgeContext): Badge | null; + createBadge(context: BadgeContext): Badge; } export interface BadgeFactories { diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 51aae31621..1b26ed69dd 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -22,6 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/core": "^0.7.1", + "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index 4160278d10..927a16a7eb 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -16,6 +16,7 @@ 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'; @@ -29,11 +30,13 @@ export class BadgesClient implements BadgesApi { public async getEntityBadgeSpecs(entity: Entity): Promise { const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity); - const specs = (await ( - await fetch(entityBadgeSpecsUrl) - ).json()) as BadgeSpec[]; + const response = await fetch(entityBadgeSpecsUrl); - return specs; + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return await response.json(); } private async getEntityBadgeSpecsUrl(entity: Entity): Promise { diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx index ac0d46f114..74a244e698 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -15,7 +15,12 @@ */ import { Entity } from '@backstage/catalog-model'; -import { CodeSnippet, Progress, useApi } from '@backstage/core'; +import { + CodeSnippet, + Progress, + ResponseErrorPanel, + useApi, +} from '@backstage/core'; import { Button, Dialog, @@ -28,7 +33,6 @@ import { useTheme, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import Alert from '@material-ui/lab/Alert'; import React from 'react'; import { useAsync } from 'react-use'; import { badgesApiRef } from '../api'; @@ -80,17 +84,11 @@ export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { return ( - Entity Badges + Entity Badges - {loading ? : null} - - {error ? ( - - {error.toString()} - - ) : null} - + {loading && } + {error && } {content} From 00a8a2d58341f7c71d9791ea01b3918886d45603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 19 Mar 2021 08:48:50 +0100 Subject: [PATCH 24/24] fix deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/badges-backend/package.json | 2 +- plugins/badges/package.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index fa6f6a4557..75f681190d 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.5", + "@backstage/backend-common": "^0.6.0", "@backstage/catalog-client": "^0.3.6", "@backstage/catalog-model": "^0.7.3", "@backstage/config": "^0.1.3", diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 1b26ed69dd..cc55a0fed8 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.4", @@ -34,9 +34,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@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",