diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 3c944846d1..18131236df 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -35,6 +35,7 @@ "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.1", + "express-promise-router": "^3.0.3", "helmet": "^3.22.0", "morgan": "^1.10.0", "stoppable": "^1.1.0", diff --git a/packages/backend-common/src/middleware/index.ts b/packages/backend-common/src/middleware/index.ts index 083b36c3e9..76c52d8830 100644 --- a/packages/backend-common/src/middleware/index.ts +++ b/packages/backend-common/src/middleware/index.ts @@ -17,3 +17,4 @@ export * from './errorHandler'; export * from './notFoundHandler'; export * from './requestLoggingHandler'; +export * from './statusCheckHandler'; diff --git a/packages/backend-common/src/middleware/statusCheckHandler.test.ts b/packages/backend-common/src/middleware/statusCheckHandler.test.ts new file mode 100644 index 0000000000..7ed1a65b58 --- /dev/null +++ b/packages/backend-common/src/middleware/statusCheckHandler.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import request from 'supertest'; +import { statusCheckHandler } from './statusCheckHandler'; + +describe('statusCheckHandler', () => { + it('gives status 200 when using default', async () => { + const app = express(); + app.use('/healthcheck', await statusCheckHandler()); + + const response = await request(app).get('/healthcheck'); + + expect(response.status).toBe(200); + expect(response.text).toBe(JSON.stringify({ status: 'ok' })); + }); + + it('gives status 200 when status function returns true', async () => { + const app = express(); + const status = { foo: 'bar' }; + const statusCheck = () => Promise.resolve(status); + app.use('/healthcheck', await statusCheckHandler({ statusCheck })); + + const response = await request(app).get('/healthcheck'); + + expect(response.status).toBe(200); + expect(response.text).toBe(JSON.stringify(status)); + }); + + it('gives status 500 when status check throws an error', async () => { + const app = express(); + const statusCheck = () => { + throw Error('error!'); + }; + app.use('/healthcheck', await statusCheckHandler({ statusCheck })); + + const response = await request(app).get('/healthcheck'); + + expect(response.status).toBe(500); + }); +}); diff --git a/packages/backend-common/src/middleware/statusCheckHandler.ts b/packages/backend-common/src/middleware/statusCheckHandler.ts new file mode 100644 index 0000000000..3f62f04f59 --- /dev/null +++ b/packages/backend-common/src/middleware/statusCheckHandler.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2020 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 { NextFunction, Request, Response, RequestHandler } from 'express'; + +export type StatusCheck = () => Promise; + +export interface StatusCheckHandlerOptions { + /** + * Optional status function which returns a message. + */ + statusCheck?: StatusCheck; +} + +/** + * Express middleware for status checks. + * + * This is commonly used to implement healthcheck and readiness routes. + * + * @param options An optional configuration object. + * @returns An Express error request handler + */ +export async function statusCheckHandler( + options: StatusCheckHandlerOptions = {}, +): Promise { + const statusCheck: StatusCheck = options.statusCheck + ? options.statusCheck + : () => Promise.resolve({ status: 'ok' }); + + return async (_request: Request, response: Response, next: NextFunction) => { + try { + const status = await statusCheck(); + response.status(200).header('').send(status); + } catch (err) { + next(err); + } + }; +} diff --git a/packages/backend-common/src/service/createStatusCheckRouter.test.ts b/packages/backend-common/src/service/createStatusCheckRouter.test.ts new file mode 100644 index 0000000000..6c15e7b5f9 --- /dev/null +++ b/packages/backend-common/src/service/createStatusCheckRouter.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 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 * as winston from 'winston'; +import request from 'supertest'; +import { createStatusCheckRouter } from './createStatusCheckRouter'; + +describe('createStatusCheckRouter', () => { + const logger = winston.createLogger(); + + it('gives status 200 when using default path', async () => { + const app = express(); + app.use('', await createStatusCheckRouter({ logger })); + + const response = await request(app).get('/healthcheck'); + + expect(response.status).toBe(200); + expect(response.text).toBe(JSON.stringify({ status: 'ok' })); + }); + + it('gives status 200 when using custom path', async () => { + const app = express(); + app.use('', await createStatusCheckRouter({ logger, path: '/ready' })); + + const response = await request(app).get('/ready'); + + expect(response.status).toBe(200); + expect(response.text).toBe(JSON.stringify({ status: 'ok' })); + }); + + it('gives status 500 when status check throws an error', async () => { + const app = express(); + const statusCheck = () => { + throw Error('error!'); + }; + app.use('', await createStatusCheckRouter({ logger, statusCheck })); + + const response = await request(app).get('/healthcheck'); + + expect(response.status).toBe(500); + }); +}); diff --git a/packages/backend-common/src/service/createStatusCheckRouter.ts b/packages/backend-common/src/service/createStatusCheckRouter.ts new file mode 100644 index 0000000000..c6014cbfc2 --- /dev/null +++ b/packages/backend-common/src/service/createStatusCheckRouter.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 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 Router from 'express-promise-router'; +import express from 'express'; +import { errorHandler, statusCheckHandler, StatusCheck } from '../middleware'; + +export interface StatusCheckRouterOptions { + logger: Logger; + path?: string; + statusCheck?: StatusCheck; +} + +export async function createStatusCheckRouter( + options: StatusCheckRouterOptions, +): Promise { + const router = Router(); + const { path = '/healthcheck', statusCheck } = options; + + router.use(path, await statusCheckHandler({ statusCheck })); + router.use(errorHandler()); + + return router; +} diff --git a/packages/backend-common/src/service/index.ts b/packages/backend-common/src/service/index.ts index 9bba9d6baf..58e310032d 100644 --- a/packages/backend-common/src/service/index.ts +++ b/packages/backend-common/src/service/index.ts @@ -15,4 +15,5 @@ */ export { createServiceBuilder } from './createServiceBuilder'; +export { createStatusCheckRouter } from './createStatusCheckRouter'; export type { ServiceBuilder } from './types'; diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts index b39df27527..070794969f 100644 --- a/packages/backend-common/src/service/types.ts +++ b/packages/backend-common/src/service/types.ts @@ -16,7 +16,7 @@ import { ConfigReader } from '@backstage/config'; import cors from 'cors'; -import { Router } from 'express'; +import { Router, RequestHandler } from 'express'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -64,7 +64,7 @@ export type ServiceBuilder = { * @param root The root URL to bind to (e.g. "/api/function1") * @param router An express router */ - addRouter(root: string, router: Router): ServiceBuilder; + addRouter(root: string, router: Router | RequestHandler): ServiceBuilder; /** * Starts the server using the given settings. diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7c98140270..40642b8cc3 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -30,6 +30,7 @@ import { import { ConfigReader, AppConfig } from '@backstage/config'; import { loadConfig } from '@backstage/config-loader'; import knex from 'knex'; +import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; import identity from './plugins/identity'; @@ -63,6 +64,7 @@ async function main() { const configReader = ConfigReader.fromConfigs(configs); const createEnv = makeCreateEnv(configs); + const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); const authEnv = useHotMemoize(module, () => createEnv('auth')); @@ -75,6 +77,7 @@ async function main() { const service = createServiceBuilder(module) .loadConfig(configReader) + .addRouter('', await healthcheck(healthcheckEnv)) .addRouter('/catalog', await catalog(catalogEnv)) .addRouter('/rollbar', await rollbar(rollbarEnv)) .addRouter('/scaffolder', await scaffolder(scaffolderEnv)) diff --git a/packages/backend/src/plugins/healthcheck.ts b/packages/backend/src/plugins/healthcheck.ts new file mode 100644 index 0000000000..3dd9a67827 --- /dev/null +++ b/packages/backend/src/plugins/healthcheck.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 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 { createStatusCheckRouter } from '@backstage/backend-common'; +import { PluginEnvironment } from '../types'; + +export default async function createRouter({ logger }: PluginEnvironment) { + return await createStatusCheckRouter({ logger, path: '/healthcheck' }); +}