diff --git a/.changeset/modern-lizards-sniff.md b/.changeset/modern-lizards-sniff.md new file mode 100644 index 0000000000..cf1994ee7f --- /dev/null +++ b/.changeset/modern-lizards-sniff.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-airbrake-backend': minor +--- + +Created the Airbrake backend plugin that proxies requests to the Airbrake API diff --git a/app-config.yaml b/app-config.yaml index b025064c74..e49e29c45a 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -464,3 +464,6 @@ apacheAirflow: gocd: baseUrl: https://your.gocd.instance.com + +airbrake: + apiKey: ${AIRBRAKE_API_KEY} diff --git a/packages/backend/package.json b/packages/backend/package.json index 41972c15d2..787530550a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -33,6 +33,7 @@ "@backstage/catalog-model": "^0.9.10", "@backstage/config": "^0.1.13", "@backstage/integration": "^0.7.2", + "@backstage/plugin-airbrake-backend": "^0.0.0", "@backstage/plugin-app-backend": "^0.3.24", "@backstage/plugin-auth-backend": "^0.10.0", "@backstage/plugin-auth-node": "^0.1.0", diff --git a/plugins/airbrake-backend/.eslintrc.js b/plugins/airbrake-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/airbrake-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/airbrake-backend/README.md b/plugins/airbrake-backend/README.md new file mode 100644 index 0000000000..42369524d6 --- /dev/null +++ b/plugins/airbrake-backend/README.md @@ -0,0 +1,16 @@ +# airbrake-backend + +Welcome to the airbrake-backend backend plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/airbrake-backend](http://localhost:7007/airbrake-backend). + +Here is an example endpoint: http://localhost:7007/airbrake-backend/health + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. + +It is only meant for local development. diff --git a/plugins/airbrake-backend/api-report.md b/plugins/airbrake-backend/api-report.md new file mode 100644 index 0000000000..b9e9a50e0b --- /dev/null +++ b/plugins/airbrake-backend/api-report.md @@ -0,0 +1,26 @@ +## API Report File for "@backstage/plugin-airbrake-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger as Logger_2 } from 'winston'; + +// @public +export interface AirbrakeConfig { + apiKey: string; +} + +// @public +export function createRouter(options: RouterOptions): Promise; + +// @public +export function extractAirbrakeConfig(config: Config): AirbrakeConfig; + +// @public +export interface RouterOptions { + airbrakeConfig: AirbrakeConfig; + logger: Logger_2; +} +``` diff --git a/plugins/airbrake-backend/config.d.ts b/plugins/airbrake-backend/config.d.ts new file mode 100644 index 0000000000..ab6a7ef537 --- /dev/null +++ b/plugins/airbrake-backend/config.d.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 Airbrake plugin */ + airbrake: { + /** + * The API Key to authenticate requests. More details on how to get this here: https://airbrake.io/docs/api/#authentication + */ + apiKey: string; + }; +} diff --git a/plugins/airbrake-backend/package.json b/plugins/airbrake-backend/package.json new file mode 100644 index 0000000000..0e94e6d998 --- /dev/null +++ b/plugins/airbrake-backend/package.json @@ -0,0 +1,58 @@ +{ + "name": "@backstage/plugin-airbrake-backend", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "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.10.7", + "@backstage/config": "^0.1.13", + "@types/express": "*", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "http-proxy-middleware": "^2.0.0", + "winston": "^3.2.1", + "cross-fetch": "^3.0.6", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.13.2", + "@types/http-proxy-middleware": "^0.19.3", + "@types/supertest": "^2.0.8", + "supertest": "^6.1.6", + "msw": "^0.35.0" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts", + "jest": { + "coverageThreshold": { + "global": { + "functions": 100, + "lines": 100, + "statements": 100 + } + }, + "coveragePathIgnorePatterns": [ + "standaloneServer.ts", + "index.ts", + "run.ts" + ] + } +} diff --git a/plugins/airbrake-backend/src/config/ExtractAirbrakeConfig.ts b/plugins/airbrake-backend/src/config/ExtractAirbrakeConfig.ts new file mode 100644 index 0000000000..ea39a27d11 --- /dev/null +++ b/plugins/airbrake-backend/src/config/ExtractAirbrakeConfig.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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'; + +/** + * The configuration needed for the airbrake-backend plugin + * + * @public + */ +export interface AirbrakeConfig { + /** + * The API Key to authenticate requests. More details on how to get this here: https://airbrake.io/docs/api/#authentication + */ + apiKey: string; +} + +/** + * Extract the Airbrake config from a config object + * + * @public + * + * @param config - The config object to extract from + */ +export function extractAirbrakeConfig(config: Config): AirbrakeConfig { + return { + apiKey: config.getString('airbrake.apiKey'), + }; +} diff --git a/plugins/airbrake-backend/src/config/index.ts b/plugins/airbrake-backend/src/config/index.ts new file mode 100644 index 0000000000..60d60c2761 --- /dev/null +++ b/plugins/airbrake-backend/src/config/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 './ExtractAirbrakeConfig'; diff --git a/plugins/airbrake-backend/src/index.ts b/plugins/airbrake-backend/src/index.ts new file mode 100644 index 0000000000..f4ec6008c2 --- /dev/null +++ b/plugins/airbrake-backend/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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. + */ + +/** + * The Airbrake Backend plugin used by \@backstage/plugin-airbrake + * + * @packageDocumentation + */ + +export * from './service/router'; +export * from './config'; diff --git a/plugins/airbrake-backend/src/run.ts b/plugins/airbrake-backend/src/run.ts new file mode 100644 index 0000000000..0a3ed2b7f0 --- /dev/null +++ b/plugins/airbrake-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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) : 7007; +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/airbrake-backend/src/service/router.test.ts b/plugins/airbrake-backend/src/service/router.test.ts new file mode 100644 index 0000000000..60c1f52126 --- /dev/null +++ b/plugins/airbrake-backend/src/service/router.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 express from 'express'; +import request from 'supertest'; +import { ConfigReader } from '@backstage/config'; +import { + createRouter, + generateAirbrakePathRewrite, + RouterOptions, +} from './router'; +import { AirbrakeConfig, extractAirbrakeConfig } from '../config'; + +describe('createRouter', () => { + let app: express.Express; + let airbrakeConfig: AirbrakeConfig; + + beforeEach(async () => { + jest.resetAllMocks(); + + const config = new ConfigReader({ + airbrake: { + apiKey: 'fakeApiKey', + }, + }); + airbrakeConfig = extractAirbrakeConfig(config); + + const router = await createRouter({ + logger: getVoidLogger(), + airbrakeConfig, + }); + app = express().use(router); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); + + describe('GET /api', () => { + it('appends the API Key properly with no other url parameters', () => { + const options: RouterOptions = { + logger: getVoidLogger(), + airbrakeConfig, + }; + const pathRewrite = generateAirbrakePathRewrite(options) as ( + path: string, + ) => string; + + expect(pathRewrite('/airbrake-backend/api/v4/random/endpoint')).toBe( + '/v4/random/endpoint?key=fakeApiKey', + ); + }); + + it('appends the API Key properly despite there being other URL parameters', () => { + const options: RouterOptions = { + logger: getVoidLogger(), + airbrakeConfig, + }; + const pathRewrite = generateAirbrakePathRewrite(options) as ( + path: string, + ) => string; + + expect( + pathRewrite( + '/airbrake-backend/api/v4/random/endpoint?param1=123¶m2=abc', + ), + ).toBe('/v4/random/endpoint?param1=123¶m2=abc&key=fakeApiKey'); + }); + }); +}); diff --git a/plugins/airbrake-backend/src/service/router.ts b/plugins/airbrake-backend/src/service/router.ts new file mode 100644 index 0000000000..b870eb5c13 --- /dev/null +++ b/plugins/airbrake-backend/src/service/router.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { errorHandler } from '@backstage/backend-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { AirbrakeConfig } from '../config'; +import { Options } from 'http-proxy-middleware/dist/types'; + +/** + * The router options that are needed when creating a router. + * + * @public + */ +export interface RouterOptions { + /** + * A logger object + */ + logger: Logger; + + /** + * The Airbrake config obtained from {@link extractAirbrakeConfig} + */ + airbrakeConfig: AirbrakeConfig; +} + +/** + * Mainly used internally to generate the path. + * + * @internal + * + * @param options - Router options + */ +export const generateAirbrakePathRewrite = ( + options: RouterOptions, +): Options['pathRewrite'] => { + const apiKey = options.airbrakeConfig.apiKey; + + return path => { + let newPath = path.replace(/.+?(\/api)/g, ''); + if (newPath.includes('?')) { + newPath += `&key=${apiKey}`; + } else { + newPath += `?key=${apiKey}`; + } + return newPath; + }; +}; + +/** + * Create the Airbrake Router, used for making API calls to the Airbrake API. + * + * @public + * + * @param options - Router options + */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger } = options; + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.send({ status: 'ok' }); + }); + + router.use( + '/api', + createProxyMiddleware({ + target: 'https://api.airbrake.io/api', + changeOrigin: true, + pathRewrite: generateAirbrakePathRewrite(options), + }), + ); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/airbrake-backend/src/service/standaloneServer.ts b/plugins/airbrake-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..5957dd0b7f --- /dev/null +++ b/plugins/airbrake-backend/src/service/standaloneServer.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { + createServiceBuilder, + loadBackendConfig, +} from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; +import { extractAirbrakeConfig } from '../config'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'airbrake-backend-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + const airbrakeConfig = extractAirbrakeConfig(config); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + airbrakeConfig, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/airbrake-backend', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/airbrake-backend/src/setupTests.ts b/plugins/airbrake-backend/src/setupTests.ts new file mode 100644 index 0000000000..d3232290a7 --- /dev/null +++ b/plugins/airbrake-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 {};