From 324b0a767a0b9ee097c2f4b236eb7b9e2e1e88f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 18 Jun 2020 11:19:01 +0200 Subject: [PATCH] feat(backend-common): base backend config --- app-config.yaml | 6 + packages/backend-common/package.json | 1 + .../src/service/createServiceBuilder.ts | 2 +- .../service/{ => lib}/ServiceBuilderImpl.ts | 60 ++++++++-- .../backend-common/src/service/lib/config.ts | 112 ++++++++++++++++++ packages/backend-common/src/service/types.ts | 8 ++ packages/backend/src/index.ts | 9 +- 7 files changed, 182 insertions(+), 16 deletions(-) rename packages/backend-common/src/service/{ => lib}/ServiceBuilderImpl.ts (69%) create mode 100644 packages/backend-common/src/service/lib/config.ts diff --git a/app-config.yaml b/app-config.yaml index 6ff336c727..c5777be4fa 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -4,6 +4,12 @@ app: backend: baseUrl: http://localhost:7000 + bindPort: 7000 + bindHost: localhost + cors: + origin: http://localhost:3000 + methods: [GET, POST, PUT, DELETE] + credentials: true organization: name: Spotify diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 1a52e06013..6a827b7e86 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -29,6 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/config": "^0.1.1-alpha.9", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "compression": "^1.7.4", diff --git a/packages/backend-common/src/service/createServiceBuilder.ts b/packages/backend-common/src/service/createServiceBuilder.ts index daef612fcf..c62921afc3 100644 --- a/packages/backend-common/src/service/createServiceBuilder.ts +++ b/packages/backend-common/src/service/createServiceBuilder.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ServiceBuilderImpl } from './ServiceBuilderImpl'; +import { ServiceBuilderImpl } from './lib/ServiceBuilderImpl'; /** * Creates a new service builder. diff --git a/packages/backend-common/src/service/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts similarity index 69% rename from packages/backend-common/src/service/ServiceBuilderImpl.ts rename to packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 931d29b841..3cceec0e87 100644 --- a/packages/backend-common/src/service/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ConfigReader } from '@backstage/config'; import compression from 'compression'; import cors from 'cors'; import express, { Router } from 'express'; @@ -21,37 +22,66 @@ import helmet from 'helmet'; import { Server } from 'http'; import stoppable from 'stoppable'; import { Logger } from 'winston'; -import { getRootLogger } from '../logging'; +import { useHotCleanup } from '../../hot'; +import { getRootLogger } from '../../logging'; import { errorHandler, notFoundHandler, requestLoggingHandler, -} from '../middleware'; -import { ServiceBuilder } from './types'; -import { useHotCleanup } from '../hot'; +} from '../../middleware'; +import { ServiceBuilder } from '../types'; +import { readBaseOptions, readCorsOptions } from './config'; const DEFAULT_PORT = 7000; +const DEFAULT_HOST = 'localhost'; export class ServiceBuilderImpl implements ServiceBuilder { private port: number | undefined; + private host: string | undefined; private logger: Logger | undefined; private corsOptions: cors.CorsOptions | undefined; private routers: [string, Router][]; - /** - * Reference to the module where builder is created - * Needed for the HMR - */ + // Reference to the module where builder is created - needed for hot module + // reloading private module: NodeModule; + constructor(module: NodeModule) { this.routers = []; this.module = module; } + loadConfig(config: ConfigReader): ServiceBuilder { + const backendConfig = config.getOptionalConfig('backend'); + if (!backendConfig) { + return this; + } + + const baseOptions = readBaseOptions(backendConfig); + if (baseOptions.bindPort) { + this.port = baseOptions.bindPort; + } + if (baseOptions.bindHost) { + this.host = baseOptions.bindHost; + } + + const corsOptions = readCorsOptions(backendConfig); + if (corsOptions) { + this.corsOptions = corsOptions; + } + + return this; + } + setPort(port: number): ServiceBuilder { this.port = port; return this; } + setHost(host: string): ServiceBuilder { + this.host = host; + return this; + } + setLogger(logger: Logger): ServiceBuilder { this.logger = logger; return this; @@ -69,7 +99,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { start(): Promise { const app = express(); - const { port, logger, corsOptions } = this.getOptions(); + const { port, host, logger, corsOptions } = this.getOptions(); app.use(helmet()); if (corsOptions) { @@ -89,8 +119,9 @@ export class ServiceBuilderImpl implements ServiceBuilder { logger.error(`Failed to start up on port ${port}, ${e}`); reject(e); }); + const server = stoppable( - app.listen(port, () => { + app.listen(port, host, () => { logger.info(`Listening on port ${port}`); }), 0, @@ -108,6 +139,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { private getOptions(): { port: number; + host: string; logger: Logger; corsOptions?: cors.CorsOptions; } { @@ -118,6 +150,13 @@ export class ServiceBuilderImpl implements ServiceBuilder { port = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT; } + let host: string; + if (this.host !== undefined) { + host = this.host; + } else { + host = process.env.HOST || DEFAULT_HOST; + } + let logger: Logger; if (this.logger) { logger = this.logger; @@ -127,6 +166,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { return { port, + host, logger, corsOptions: this.corsOptions, }; diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts new file mode 100644 index 0000000000..ead13cccec --- /dev/null +++ b/packages/backend-common/src/service/lib/config.ts @@ -0,0 +1,112 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { CorsOptions } from 'cors'; + +export type BaseOptions = { + bindPort?: number; + bindHost?: string; +}; + +/** + * Reads some base options out of a config object. + * + * @param config The root of a backend config object + * @returns A base options object + * + * @example + * ```json + * { + * baseUrl: "http://localhost:7000" + * bindPort: 7000 + * bindHost: "0.0.0.0" + * } + * ``` + */ +export function readBaseOptions(config: ConfigReader): BaseOptions { + return removeUnknown({ + bindPort: config.getOptionalNumber('bindPort'), + bindHost: config.getOptionalString('bindHost'), + }); +} + +/** + * Attempts to read a CORS options object from the root of a config object. + * + * @param config The root of a backend config object + * @returns A CORS options object, or undefined if not specified + * + * @example + * ```json + * { + * cors: { + * origin: "http://localhost:3000", + * credentials: true + * } + * } + * ``` + */ +export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { + const cc = config.getOptionalConfig('cors'); + if (!cc) { + return undefined; + } + + return removeUnknown({ + origin: getOptionalStringOrStrings(cc, 'origin'), + methods: getOptionalStringOrStrings(cc, 'methods'), + allowedHeaders: getOptionalStringOrStrings(cc, 'allowedHeaders'), + exposedHeaders: getOptionalStringOrStrings(cc, 'exposedHeaders'), + credentials: cc.getOptionalBoolean('credentials'), + maxAge: cc.getOptionalNumber('maxAge'), + preflightContinue: cc.getOptionalBoolean('preflightContinue'), + optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'), + }); +} + +function getOptionalStringOrStrings( + config: ConfigReader, + key: string, +): string | string[] | undefined { + const value = config.getOptional(key); + if ( + value === undefined || + typeof value === 'string' || + isStringArray(value) + ) { + return value; + } + throw new Error(`Expected string or array of strings, got ${typeof value}`); +} + +function isStringArray(value: any): value is string[] { + if (!Array.isArray(value)) { + return false; + } + for (const v of value) { + if (typeof v !== 'string') { + return false; + } + } + return true; +} + +function removeUnknown(obj: T): T { + return Object.fromEntries( + Object.entries(obj).filter(([, v]) => v !== undefined), + ) as T; +} diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts index 306f0d587e..b39df27527 100644 --- a/packages/backend-common/src/service/types.ts +++ b/packages/backend-common/src/service/types.ts @@ -14,12 +14,20 @@ * limitations under the License. */ +import { ConfigReader } from '@backstage/config'; import cors from 'cors'; import { Router } from 'express'; import { Server } from 'http'; import { Logger } from 'winston'; export type ServiceBuilder = { + /** + * Sets the service parameters based on configuration. + * + * @param config The configuration to read + */ + loadConfig(config: ConfigReader): ServiceBuilder; + /** * Sets the port to listen on. * diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index df0d08fa7d..3246c5f6f5 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -55,7 +55,9 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { } async function main() { - const createEnv = makeCreateEnv(await loadConfig()); + const configs = await loadConfig(); + const configReader = ConfigReader.fromConfigs(configs); + const createEnv = makeCreateEnv(configs); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); @@ -63,10 +65,7 @@ async function main() { const identityEnv = useHotMemoize(module, () => createEnv('identity')); const service = createServiceBuilder(module) - .enableCors({ - origin: 'http://localhost:3000', - credentials: true, - }) + .loadConfig(configReader) .addRouter('/catalog', await catalog(catalogEnv)) .addRouter('/scaffolder', await scaffolder(scaffolderEnv)) .addRouter(