From ae6a5a041f1e69b6c96f307ed59c9147494db6d2 Mon Sep 17 00:00:00 2001 From: Todd Kennedy Date: Wed, 29 May 2019 13:00:56 -0700 Subject: [PATCH] [feat] create additional http servers (#36804) * [feat] create additional http servers allow for additional http servers to be created, tracked and returned * respond to pr feedback * tweak test * update documentation * destructure port, remove unnecessary imports * [fix] export correct type * [feat] expose createNewServer to plugins * [fix] respond to pr feedback * todo: add schema validation & integration test * use reach * [fix] use validateKey to validate partial * [fix] change config shadowing * check kibana port & prevent shadowing * centralize start/stop for servers, add integration test * remove unnecessary property * never forget your await * remove option to pass config into start * fix pr feedback * fix documentation * fix test failures --- .../kibana-plugin-server.coresetup.http.md | 1 + .../server/kibana-plugin-server.coresetup.md | 41 ++++--- ...server.httpservicesetup.createnewserver.md | 11 ++ .../kibana-plugin-server.httpservicesetup.md | 11 +- .../core/server/kibana-plugin-server.md | 110 +++++++++--------- src/core/server/http/http_server.test.ts | 51 ++++---- src/core/server/http/http_server.ts | 20 ++-- src/core/server/http/http_service.mock.ts | 4 + .../server/http/http_service.test.mocks.ts | 11 +- src/core/server/http/http_service.test.ts | 45 ++++++- src/core/server/http/http_service.ts | 55 ++++++++- src/core/server/index.ts | 1 + src/core/server/plugins/plugin_context.ts | 1 + src/core/server/server.api.md | 9 +- 14 files changed, 242 insertions(+), 129 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-server.httpservicesetup.createnewserver.md diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.http.md b/docs/development/core/server/kibana-plugin-server.coresetup.http.md index 8c547ca2a42a9..46d44d01bd8b0 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.http.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.http.md @@ -12,5 +12,6 @@ http: { registerOnRequest: HttpServiceSetup['registerOnRequest']; getBasePathFor: HttpServiceSetup['getBasePathFor']; setBasePathFor: HttpServiceSetup['setBasePathFor']; + createNewServer: HttpServiceSetup['createNewServer']; }; ``` diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.md b/docs/development/core/server/kibana-plugin-server.coresetup.md index fe6d3ee71edc8..287eb5f066bfc 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.md @@ -1,21 +1,20 @@ - - -[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [CoreSetup](./kibana-plugin-server.coresetup.md) - -## CoreSetup interface - -Context passed to the plugins `setup` method. - -Signature: - -```typescript -export interface CoreSetup -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [elasticsearch](./kibana-plugin-server.coresetup.elasticsearch.md) | {`

` adminClient$: Observable<ClusterClient>;`

` dataClient$: Observable<ClusterClient>;`

` } | | -| [http](./kibana-plugin-server.coresetup.http.md) | {`

` registerAuth: HttpServiceSetup['registerAuth'];`

` registerOnRequest: HttpServiceSetup['registerOnRequest'];`

` getBasePathFor: HttpServiceSetup['getBasePathFor'];`

` setBasePathFor: HttpServiceSetup['setBasePathFor'];`

` } | | - + + +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [CoreSetup](./kibana-plugin-server.coresetup.md) + +## CoreSetup interface + +Context passed to the plugins `setup` method. + +Signature: + +```typescript +export interface CoreSetup +``` + +## Properties + +| Property | Type | Description | +| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| [elasticsearch](./kibana-plugin-server.coresetup.elasticsearch.md) | {`

` adminClient\$: Observable<ClusterClient>;`

` dataClient\$: Observable<ClusterClient>;`

` } | | +| [http](./kibana-plugin-server.coresetup.http.md) | {`

` registerOnPreAuth: HttpServiceSetup['registerOnPreAuth'];`

` registerAuth: HttpServiceSetup['registerAuth'];`

` registerOnPostAuth: HttpServiceSetup['registerOnPostAuth'];`

` getBasePathFor: HttpServiceSetup['getBasePathFor'];`

` setBasePathFor: HttpServiceSetup['setBasePathFor'];`

` createNewServer: HttpServiceSetup['createNewServer'];`

` } | | diff --git a/docs/development/core/server/kibana-plugin-server.httpservicesetup.createnewserver.md b/docs/development/core/server/kibana-plugin-server.httpservicesetup.createnewserver.md new file mode 100644 index 0000000000000..e33f1c51abee9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpservicesetup.createnewserver.md @@ -0,0 +1,11 @@ + + +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) > [createNewServer](./kibana-plugin-server.httpservicesetup.createnewserver.md) + +## HttpServiceSetup.createNewServer property + +Signature: + +```typescript +createNewServer: (cfg: Partial) => Promise; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpservicesetup.md b/docs/development/core/server/kibana-plugin-server.httpservicesetup.md index 36335c0d5f4cd..a1b77ba466399 100644 --- a/docs/development/core/server/kibana-plugin-server.httpservicesetup.md +++ b/docs/development/core/server/kibana-plugin-server.httpservicesetup.md @@ -2,11 +2,18 @@ [Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) -## HttpServiceSetup type +## HttpServiceSetup interface Signature: ```typescript -export declare type HttpServiceSetup = HttpServerSetup; +export interface HttpServiceSetup extends HttpServerSetup ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [createNewServer](./kibana-plugin-server.httpservicesetup.createnewserver.md) | (cfg: Partial<HttpConfig>) => Promise<HttpServerSetup> | | + diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index c078a5fdc983f..a31166302087e 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -1,55 +1,55 @@ - - -[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) - -## kibana-plugin-server package - -The Kibana Core APIs for server-side plugins. - -A plugin's `server/index` file must contain a named import, `plugin`, that implements [PluginInitializer](./kibana-plugin-server.plugininitializer.md) which returns an object that implements [Plugin](./kibana-plugin-server.plugin.md). - -The plugin integrates with the core system via lifecycle events: `setup`, `start`, and `stop`. In each lifecycle method, the plugin will receive the corresponding core services available (either [CoreSetup](./kibana-plugin-server.coresetup.md) or [CoreStart](./kibana-plugin-server.corestart.md)) and any interfaces returned by dependency plugins' lifecycle method. Anything returned by the plugin's lifecycle method will be exposed to downstream dependencies when their corresponding lifecycle methods are invoked. - -## Classes - -| Class | Description | -| --- | --- | -| [ClusterClient](./kibana-plugin-server.clusterclient.md) | Represents an Elasticsearch cluster API client and allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)). | -| [KibanaRequest](./kibana-plugin-server.kibanarequest.md) | | -| [Router](./kibana-plugin-server.router.md) | | -| [ScopedClusterClient](./kibana-plugin-server.scopedclusterclient.md) | Serves the same purpose as "normal" ClusterClient but exposes additional callAsCurrentUser method that doesn't use credentials of the Kibana internal user (as callAsInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API | - -## Interfaces - -| Interface | Description | -| --- | --- | -| [AuthToolkit](./kibana-plugin-server.authtoolkit.md) | A tool set defining an outcome of Auth interceptor for incoming request. | -| [CallAPIOptions](./kibana-plugin-server.callapioptions.md) | The set of options that defines how API call should be made and result be processed. | -| [CoreSetup](./kibana-plugin-server.coresetup.md) | Context passed to the plugins setup method. | -| [CoreStart](./kibana-plugin-server.corestart.md) | Context passed to the plugins start method. | -| [DiscoveredPlugin](./kibana-plugin-server.discoveredplugin.md) | Small container object used to expose information about discovered plugins that may or may not have been started. | -| [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) | | -| [HttpServiceStart](./kibana-plugin-server.httpservicestart.md) | | -| [InternalCoreStart](./kibana-plugin-server.internalcorestart.md) | | -| [Logger](./kibana-plugin-server.logger.md) | Logger exposes all the necessary methods to log any type of information and this is the interface used by the logging consumers including plugins. | -| [LoggerFactory](./kibana-plugin-server.loggerfactory.md) | The single purpose of LoggerFactory interface is to define a way to retrieve a context-based logger instance. | -| [LogMeta](./kibana-plugin-server.logmeta.md) | Contextual metadata | -| [OnRequestToolkit](./kibana-plugin-server.onrequesttoolkit.md) | A tool set defining an outcome of OnRequest interceptor for incoming request. | -| [Plugin](./kibana-plugin-server.plugin.md) | The interface that should be returned by a PluginInitializer. | -| [PluginInitializerContext](./kibana-plugin-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. | -| [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) | | -| [PluginsServiceStart](./kibana-plugin-server.pluginsservicestart.md) | | - -## Type Aliases - -| Type Alias | Description | -| --- | --- | -| [APICaller](./kibana-plugin-server.apicaller.md) | | -| [AuthenticationHandler](./kibana-plugin-server.authenticationhandler.md) | | -| [ElasticsearchClientConfig](./kibana-plugin-server.elasticsearchclientconfig.md) | | -| [Headers](./kibana-plugin-server.headers.md) | | -| [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) | | -| [OnRequestHandler](./kibana-plugin-server.onrequesthandler.md) | | -| [PluginInitializer](./kibana-plugin-server.plugininitializer.md) | The plugin export at the root of a plugin's server directory should conform to this interface. | -| [PluginName](./kibana-plugin-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. | - + + +[Home](./index) > [kibana-plugin-server](./kibana-plugin-server.md) + +## kibana-plugin-server package + +The Kibana Core APIs for server-side plugins. + +A plugin's `server/index` file must contain a named import, `plugin`, that implements [PluginInitializer](./kibana-plugin-server.plugininitializer.md) which returns an object that implements [Plugin](./kibana-plugin-server.plugin.md). + +The plugin integrates with the core system via lifecycle events: `setup`, `start`, and `stop`. In each lifecycle method, the plugin will receive the corresponding core services available (either [CoreSetup](./kibana-plugin-server.coresetup.md) or [CoreStart](./kibana-plugin-server.corestart.md)) and any interfaces returned by dependency plugins' lifecycle method. Anything returned by the plugin's lifecycle method will be exposed to downstream dependencies when their corresponding lifecycle methods are invoked. + +## Classes + +| Class | Description | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [ClusterClient](./kibana-plugin-server.clusterclient.md) | Represents an Elasticsearch cluster API client and allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)). | +| [KibanaRequest](./kibana-plugin-server.kibanarequest.md) | | +| [Router](./kibana-plugin-server.router.md) | | +| [ScopedClusterClient](./kibana-plugin-server.scopedclusterclient.md) | Serves the same purpose as "normal" ClusterClient but exposes additional callAsCurrentUser method that doesn't use credentials of the Kibana internal user (as callAsInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API | + +## Interfaces + +| Interface | Description | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| [AuthToolkit](./kibana-plugin-server.authtoolkit.md) | A tool set defining an outcome of Auth interceptor for incoming request. | +| [CallAPIOptions](./kibana-plugin-server.callapioptions.md) | The set of options that defines how API call should be made and result be processed. | +| [CoreSetup](./kibana-plugin-server.coresetup.md) | Context passed to the plugins setup method. | +| [CoreStart](./kibana-plugin-server.corestart.md) | Context passed to the plugins start method. | +| [DiscoveredPlugin](./kibana-plugin-server.discoveredplugin.md) | Small container object used to expose information about discovered plugins that may or may not have been started. | +| [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) | | +| [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) | | +| [HttpServiceStart](./kibana-plugin-server.httpservicestart.md) | | +| [InternalCoreStart](./kibana-plugin-server.internalcorestart.md) | | +| [Logger](./kibana-plugin-server.logger.md) | Logger exposes all the necessary methods to log any type of information and this is the interface used by the logging consumers including plugins. | +| [LoggerFactory](./kibana-plugin-server.loggerfactory.md) | The single purpose of LoggerFactory interface is to define a way to retrieve a context-based logger instance. | +| [LogMeta](./kibana-plugin-server.logmeta.md) | Contextual metadata | +| [OnRequestToolkit](./kibana-plugin-server.onrequesttoolkit.md) | A tool set defining an outcome of OnRequest interceptor for incoming request. | +| [Plugin](./kibana-plugin-server.plugin.md) | The interface that should be returned by a PluginInitializer. | +| [PluginInitializerContext](./kibana-plugin-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. | +| [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) | | +| [PluginsServiceStart](./kibana-plugin-server.pluginsservicestart.md) | | + +## Type Aliases + +| Type Alias | Description | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| [APICaller](./kibana-plugin-server.apicaller.md) | | +| [AuthenticationHandler](./kibana-plugin-server.authenticationhandler.md) | | +| [ElasticsearchClientConfig](./kibana-plugin-server.elasticsearchclientconfig.md) | | +| [Headers](./kibana-plugin-server.headers.md) | | +| [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) | | +| [OnRequestHandler](./kibana-plugin-server.onrequesthandler.md) | | +| [PluginInitializer](./kibana-plugin-server.plugininitializer.md) | The plugin export at the root of a plugin's server directory should conform to this interface. | +| [PluginName](./kibana-plugin-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. | diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 21e1193471972..19839f99a4968 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -58,7 +58,7 @@ test('listening after started', async () => { expect(server.isListening()).toBe(false); await server.setup(config); - await server.start(config); + await server.start(); expect(server.isListening()).toBe(true); }); @@ -72,7 +72,7 @@ test('200 OK with body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .get('/foo/') @@ -92,7 +92,7 @@ test('202 Accepted with body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .get('/foo/') @@ -112,7 +112,7 @@ test('204 No content', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .get('/foo/') @@ -134,7 +134,7 @@ test('400 Bad request with error', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .get('/foo/') @@ -164,7 +164,7 @@ test('valid params', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .get('/foo/some-string') @@ -194,7 +194,7 @@ test('invalid params', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .get('/foo/some-string') @@ -227,7 +227,7 @@ test('valid query', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .get('/foo/?bar=test&quux=123') @@ -257,7 +257,7 @@ test('invalid query', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .get('/foo/?bar=test') @@ -290,7 +290,7 @@ test('valid body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .post('/foo/') @@ -324,7 +324,7 @@ test('invalid body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .post('/foo/') @@ -357,7 +357,7 @@ test('handles putting', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .put('/foo/') @@ -388,7 +388,7 @@ test('handles deleting', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .delete('/foo/3') @@ -414,7 +414,7 @@ test('filtered headers', async () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .get('/foo/?bar=quux') @@ -444,10 +444,10 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { res.ok({ key: 'value:/foo' }) ); - const { registerRouter, server: innerServer } = await server.setup(config); + const { registerRouter, server: innerServer } = await server.setup(configWithBasePath); registerRouter(router); - await server.start(configWithBasePath); + await server.start(); innerServerListener = innerServer.listener; }); @@ -489,16 +489,9 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { }); describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { - let configWithBasePath: HttpConfig; let innerServerListener: Server; beforeEach(async () => { - configWithBasePath = { - ...config, - basePath: '/bar', - rewriteBasePath: true, - } as HttpConfig; - const router = new Router('/'); router.get({ path: '/', validate: false }, async (req, res) => res.ok({ key: 'value:/' })); router.get({ path: '/foo', validate: false }, async (req, res) => @@ -508,7 +501,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { const { registerRouter, server: innerServer } = await server.setup(config); registerRouter(router); - await server.start(configWithBasePath); + await server.start(); innerServerListener = innerServer.listener; }); @@ -571,10 +564,10 @@ describe('with defined `redirectHttpFromPort`', () => { const router = new Router('/'); router.get({ path: '/', validate: false }, async (req, res) => res.ok({ key: 'value:/' })); - const { registerRouter } = await server.setup(config); + const { registerRouter } = await server.setup(configWithSSL); registerRouter(router); - await server.start(configWithSSL); + await server.start(); }); }); @@ -610,7 +603,7 @@ test('registers onRequest interceptor several times', async () => { }); test('throws an error if starts without set up', async () => { - await expect(server.start(config)).rejects.toThrowErrorMatchingInlineSnapshot( + await expect(server.start()).rejects.toThrowErrorMatchingInlineSnapshot( `"Http server is not setup up yet"` ); }); @@ -634,7 +627,7 @@ test('#getBasePathFor() returns base path associated with an incoming request', router.get({ path: '/', validate: false }, (req, res) => res.ok({ key: getBasePathFor(req) })); registerRouter(router); - await server.start(config); + await server.start(); await supertest(innerServer.listener) .get('/') .expect(200) @@ -668,7 +661,7 @@ test('#getBasePathFor() is based on server base path', async () => { ); registerRouter(router); - await server.start(configWithBasePath); + await server.start(); await supertest(innerServer.listener) .get('/') .expect(200) diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 6dbae8a14d601..3305f3433d5a3 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -55,6 +55,7 @@ export interface HttpServerSetup { export class HttpServer { private server?: Server; + private config?: HttpConfig; private registeredRouters = new Set(); private authRegistered = false; private basePathCache = new WeakMap< @@ -102,6 +103,7 @@ export class HttpServer { public setup(config: HttpConfig): HttpServerSetup { const serverOptions = getServerOptions(config); this.server = createServer(serverOptions); + this.config = config; return { options: serverOptions, @@ -120,13 +122,13 @@ export class HttpServer { }; } - public async start(config: HttpConfig) { + public async start() { if (this.server === undefined) { throw new Error('Http server is not setup up yet'); } this.log.debug('starting http server'); - this.setupBasePathRewrite(this.server, config); + this.setupBasePathRewrite(this.server); for (const router of this.registeredRouters) { for (const route of router.getRoutes()) { @@ -139,12 +141,8 @@ export class HttpServer { } await this.server.start(); - - this.log.debug( - `http server running at ${this.server.info.uri}${ - config.rewriteBasePath ? config.basePath : '' - }` - ); + const serverPath = this.config!.rewriteBasePath || this.config!.basePath || ''; + this.log.debug(`http server running at ${this.server.info.uri}${serverPath}`); } public async stop() { @@ -157,12 +155,12 @@ export class HttpServer { this.server = undefined; } - private setupBasePathRewrite(server: Server, config: HttpConfig) { - if (config.basePath === undefined || !config.rewriteBasePath) { + private setupBasePathRewrite(server: Server) { + if (this.config!.basePath === undefined || this.config!.rewriteBasePath) { return; } - const basePath = config.basePath; + const basePath = this.config!.basePath; server.ext('onRequest', (request, responseToolkit) => { const newURL = modifyUrl(request.url.href!, urlParts => { if (urlParts.pathname != null && urlParts.pathname.startsWith(basePath)) { diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 289eae0990531..54a93d5059599 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -19,6 +19,8 @@ import { Server, ServerOptions } from 'hapi'; import { HttpService } from './http_service'; +import { HttpConfig } from './http_config'; +import { HttpServerSetup } from './http_server'; const createSetupContractMock = () => { const setupContract = { @@ -30,6 +32,8 @@ const createSetupContractMock = () => { setBasePathFor: jest.fn(), // we can mock some hapi server method when we need it server: {} as Server, + createNewServer: async (cfg: Partial): Promise => + ({} as HttpServerSetup), }; return setupContract; }; diff --git a/src/core/server/http/http_service.test.mocks.ts b/src/core/server/http/http_service.test.mocks.ts index a0d7ff5069eb0..c147944f2b7d8 100644 --- a/src/core/server/http/http_service.test.mocks.ts +++ b/src/core/server/http/http_service.test.mocks.ts @@ -19,6 +19,11 @@ export const mockHttpServer = jest.fn(); -jest.mock('./http_server', () => ({ - HttpServer: mockHttpServer, -})); +jest.mock('./http_server', () => { + const realHttpServer = jest.requireActual('./http_server'); + + return { + ...realHttpServer, + HttpServer: mockHttpServer, + }; +}); diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 7b3fd024b477c..16f946ffcc7ae 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -76,6 +76,46 @@ test('creates and sets up http server', async () => { expect(httpServer.start).toHaveBeenCalledTimes(1); }); +// this is an integration test! +test('creates and sets up second http server', async () => { + const configService = createConfigService({ + host: 'localhost', + port: 1234, + }); + const { HttpServer } = jest.requireActual('./http_server'); + + mockHttpServer.mockImplementation((...args) => new HttpServer(...args)); + + const service = new HttpService({ configService, env, logger }); + const serverSetup = await service.setup(); + const cfg = { port: 2345 }; + await serverSetup.createNewServer(cfg); + const server = await service.start(); + expect(server.isListening()).toBeTruthy(); + expect(server.isListening(cfg.port)).toBeTruthy(); + + try { + await serverSetup.createNewServer(cfg); + } catch (err) { + expect(err.message).toBe('port 2345 is already in use'); + } + + try { + await serverSetup.createNewServer({ port: 1234 }); + } catch (err) { + expect(err.message).toBe('port 1234 is already in use'); + } + + try { + await serverSetup.createNewServer({ host: 'example.org' }); + } catch (err) { + expect(err.message).toBe('port must be defined'); + } + await service.stop(); + expect(server.isListening()).toBeFalsy(); + expect(server.isListening(cfg.port)).toBeFalsy(); +}); + test('logs error if already set up', async () => { const configService = createConfigService(); @@ -153,8 +193,9 @@ test('returns http server contract on setup', async () => { })); const service = new HttpService({ configService, env, logger }); - - expect(await service.setup()).toBe(httpServer); + const { createNewServer, ...setupHttpServer } = await service.setup(); + expect(createNewServer).toBeDefined(); + expect(setupHttpServer).toEqual(httpServer); }); test('does not start http server if process is dev cluster master', async () => { diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 3a9fcb7be54e7..fec3774e2f366 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -20,15 +20,18 @@ import { Observable, Subscription } from 'rxjs'; import { first, map } from 'rxjs/operators'; +import { LoggerFactory } from '../logging'; import { CoreService } from '../../types'; import { Logger } from '../logging'; import { CoreContext } from '../core_context'; -import { HttpConfig, HttpConfigType } from './http_config'; +import { HttpConfig, HttpConfigType, config as httpConfig } from './http_config'; import { HttpServer, HttpServerSetup } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; /** @public */ -export type HttpServiceSetup = HttpServerSetup; +export interface HttpServiceSetup extends HttpServerSetup { + createNewServer: (cfg: Partial) => Promise; +} /** @public */ export interface HttpServiceStart { /** Indicates if http server is listening on a port */ @@ -38,13 +41,16 @@ export interface HttpServiceStart { /** @internal */ export class HttpService implements CoreService { private readonly httpServer: HttpServer; + private readonly secondaryServers: Map = new Map(); private readonly httpsRedirectServer: HttpsRedirectServer; private readonly config$: Observable; private configSubscription?: Subscription; + private readonly logger: LoggerFactory; private readonly log: Logger; constructor(private readonly coreContext: CoreContext) { + this.logger = coreContext.logger; this.log = coreContext.logger.get('http'); this.config$ = coreContext.configService .atPath('server') @@ -69,7 +75,12 @@ export class HttpService implements CoreService server.start())); } return { - isListening: () => this.httpServer.isListening(), + isListening: (port = 0) => { + const server = this.secondaryServers.get(port); + if (server) return server.isListening(); + return this.httpServer.isListening(); + }, }; } + private async createServer(cfg: Partial) { + const { port } = cfg; + const config = await this.config$.pipe(first()).toPromise(); + + if (!port) { + throw new Error('port must be defined'); + } + + // verify that main server and none of the secondary servers are already using this port + if (this.secondaryServers.has(port) || config.port === port) { + throw new Error(`port ${port} is already in use`); + } + + for (const [key, val] of Object.entries(cfg)) { + httpConfig.schema.validateKey(key, val); + } + + const baseConfig = await this.config$.pipe(first()).toPromise(); + const finalConfig = { ...baseConfig, ...cfg }; + const log = this.logger.get('http', `server:${port}`); + + const httpServer = new HttpServer(log); + const httpSetup = await httpServer.setup(finalConfig); + this.secondaryServers.set(port, httpServer); + return httpSetup; + } + public async stop() { if (this.configSubscription === undefined) { return; @@ -104,5 +147,7 @@ export class HttpService implements CoreService s.stop())); + this.secondaryServers.clear(); } } diff --git a/src/core/server/index.ts b/src/core/server/index.ts index d3223674eb00c..02452f7b0756f 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -83,6 +83,7 @@ export interface CoreSetup { registerOnRequest: HttpServiceSetup['registerOnRequest']; getBasePathFor: HttpServiceSetup['getBasePathFor']; setBasePathFor: HttpServiceSetup['setBasePathFor']; + createNewServer: HttpServiceSetup['createNewServer']; }; } diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index e533ac0cfe9ac..ddd65b198baa6 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -121,6 +121,7 @@ export function createPluginSetupContext( registerOnRequest: deps.http.registerOnRequest, getBasePathFor: deps.http.getBasePathFor, setBasePathFor: deps.http.setBasePathFor, + createNewServer: deps.http.createNewServer, }, }; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 821d85a3812f3..b9148a2b94a57 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -4,6 +4,7 @@ ```ts +import { ByteSizeValue } from '@kbn/config-schema'; import { ConfigOptions } from 'elasticsearch'; import { Duration } from 'moment'; import { ObjectType } from '@kbn/config-schema'; @@ -87,6 +88,7 @@ export interface CoreSetup { registerOnRequest: HttpServiceSetup['registerOnRequest']; getBasePathFor: HttpServiceSetup['getBasePathFor']; setBasePathFor: HttpServiceSetup['setBasePathFor']; + createNewServer: HttpServiceSetup['createNewServer']; }; } @@ -132,7 +134,12 @@ export type Headers = Record; // Warning: (ae-forgotten-export) The symbol "HttpServerSetup" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export type HttpServiceSetup = HttpServerSetup; +export interface HttpServiceSetup extends HttpServerSetup { + // Warning: (ae-forgotten-export) The symbol "HttpConfig" needs to be exported by the entry point index.d.ts + // + // (undocumented) + createNewServer: (cfg: Partial) => Promise; +} // @public (undocumented) export interface HttpServiceStart {