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 1/2] 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( From 026b9547b91e5500d974c4021f23f37ae7165ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 18 Jun 2020 20:52:25 +0200 Subject: [PATCH 2/2] address comments --- app-config.yaml | 3 +- .../src/service/lib/ServiceBuilderImpl.ts | 37 ++++--------------- .../backend-common/src/service/lib/config.ts | 28 ++++++++++---- 3 files changed, 30 insertions(+), 38 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index c5777be4fa..39527c20bd 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -4,8 +4,7 @@ app: backend: baseUrl: http://localhost:7000 - bindPort: 7000 - bindHost: localhost + listen: 0.0.0.0:7000 cors: origin: http://localhost:3000 methods: [GET, POST, PUT, DELETE] diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 3cceec0e87..19418b2671 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -57,11 +57,11 @@ export class ServiceBuilderImpl implements ServiceBuilder { } const baseOptions = readBaseOptions(backendConfig); - if (baseOptions.bindPort) { - this.port = baseOptions.bindPort; + if (baseOptions.listenPort) { + this.port = baseOptions.listenPort; } - if (baseOptions.bindHost) { - this.host = baseOptions.bindHost; + if (baseOptions.listenHost) { + this.host = baseOptions.listenHost; } const corsOptions = readCorsOptions(backendConfig); @@ -122,7 +122,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { const server = stoppable( app.listen(port, host, () => { - logger.info(`Listening on port ${port}`); + logger.info(`Listening on ${host}:${port}`); }), 0, ); @@ -143,31 +143,10 @@ export class ServiceBuilderImpl implements ServiceBuilder { logger: Logger; corsOptions?: cors.CorsOptions; } { - let port: number; - if (this.port !== undefined) { - port = this.port; - } else { - 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; - } else { - logger = getRootLogger(); - } - return { - port, - host, - logger, + port: this.port ?? DEFAULT_PORT, + host: this.host ?? DEFAULT_HOST, + logger: this.logger ?? getRootLogger(), corsOptions: this.corsOptions, }; } diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index ead13cccec..064eacc4f2 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -18,8 +18,8 @@ import { ConfigReader } from '@backstage/config'; import { CorsOptions } from 'cors'; export type BaseOptions = { - bindPort?: number; - bindHost?: string; + listenPort?: number; + listenHost?: string; }; /** @@ -31,16 +31,17 @@ export type BaseOptions = { * @example * ```json * { - * baseUrl: "http://localhost:7000" - * bindPort: 7000 - * bindHost: "0.0.0.0" + * baseUrl: "http://localhost:7000", + * listen: "0.0.0.0:7000" * } * ``` */ export function readBaseOptions(config: ConfigReader): BaseOptions { + // TODO(freben): Expand this to support more addresses and perhaps optional + const { host, port } = parseListenAddress(config.getString('listen')); return removeUnknown({ - bindPort: config.getOptionalNumber('bindPort'), - bindHost: config.getOptionalString('bindHost'), + listenPort: port, + listenHost: host, }); } @@ -110,3 +111,16 @@ function removeUnknown(obj: T): T { Object.entries(obj).filter(([, v]) => v !== undefined), ) as T; } + +function parseListenAddress(value: string): { host?: string; port?: number } { + const parts = value.split(':'); + if (parts.length === 1) { + return { port: parseInt(parts[0], 10) }; + } + if (parts.length === 2) { + return { host: parts[0], port: parseInt(parts[1], 10) }; + } + throw new Error( + `Unable to parse listen address ${value}, expected or :`, + ); +}