diff --git a/docs/development/core/public/kibana-plugin-public.contextcontainer.createhandler.md b/docs/development/core/public/kibana-plugin-public.contextcontainer.createhandler.md new file mode 100644 index 000000000000000..899b35c07e6ec69 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.contextcontainer.createhandler.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ContextContainer](./kibana-plugin-public.contextcontainer.md) > [createHandler](./kibana-plugin-public.contextcontainer.createhandler.md) + +## ContextContainer.createHandler() method + +Create a new handler function pre-wired to context for the plugin. + +Signature: + +```typescript +createHandler(handler: Handler): (...rest: THandlerParameters) => Promisify; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| handler | Handler<TContext, THandlerReturn, THandlerParameters> | | + +Returns: + +`(...rest: THandlerParameters) => Promisify` + +## Remarks + +This must be called when the handler is registered by the consuming plugin. If this is called later in the lifecycle it will throw an exception. + diff --git a/docs/development/core/public/kibana-plugin-public.contextcontainer.md b/docs/development/core/public/kibana-plugin-public.contextcontainer.md new file mode 100644 index 000000000000000..8b1ae8c9e96ada5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.contextcontainer.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ContextContainer](./kibana-plugin-public.contextcontainer.md) + +## ContextContainer interface + +An object that handles registration of context providers and building of new context objects. + +Signature: + +```typescript +export interface ContextContainer +``` + +## Methods + +| Method | Description | +| --- | --- | +| [createHandler(handler)](./kibana-plugin-public.contextcontainer.createhandler.md) | Create a new handler function pre-wired to context for the plugin. | +| [registerContext(contextName, provider)](./kibana-plugin-public.contextcontainer.registercontext.md) | Register a new context provider. Throws an exception if more than one provider is registered for the same context key. | + +## Remarks + +A `ContextContainer` can be used by any Core service or plugin (known as the "service owner") which wishes to expose APIs in a handler function. The container object will manage registering context providers and building a context object for a handler with all of the contexts that should be exposed to the handler's plugin. This is dependent on the dependencies that the handler's plugin declares. + +Contexts providers are executed in the order they were registered. Each provider gets access to context values provided by any plugins that it depends on. + diff --git a/docs/development/core/public/kibana-plugin-public.contextcontainer.registercontext.md b/docs/development/core/public/kibana-plugin-public.contextcontainer.registercontext.md new file mode 100644 index 000000000000000..22047b66d02151a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.contextcontainer.registercontext.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ContextContainer](./kibana-plugin-public.contextcontainer.md) > [registerContext](./kibana-plugin-public.contextcontainer.registercontext.md) + +## ContextContainer.registerContext() method + +Register a new context provider. Throws an exception if more than one provider is registered for the same context key. + +Signature: + +```typescript +registerContext(contextName: TContextName, provider: ContextProvider): this; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| contextName | TContextName | The key of the TContext object this provider supplies the value for. | +| provider | ContextProvider<TContext, TContextName, THandlerParameters> | A [ContextProvider](./kibana-plugin-public.contextprovider.md) to be called each time a new context is created. | + +Returns: + +`this` + +The `ContextContainer` for method chaining. + diff --git a/docs/development/core/public/kibana-plugin-public.contextprovider.md b/docs/development/core/public/kibana-plugin-public.contextprovider.md new file mode 100644 index 000000000000000..2e16e0568c90a4f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.contextprovider.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ContextProvider](./kibana-plugin-public.contextprovider.md) + +## ContextProvider type + +A function that returns a context value for a specific key of given context type. + +Signature: + +```typescript +export declare type ContextProvider = (context: Partial, ...rest: TProviderParameters) => Promise | TContext[TContextName]; +``` + +## Remarks + +This function will be called each time a new context is built for a handler invocation. + diff --git a/docs/development/core/public/kibana-plugin-public.contextsetup.createcontextcontainer.md b/docs/development/core/public/kibana-plugin-public.contextsetup.createcontextcontainer.md new file mode 100644 index 000000000000000..88c661228cdc000 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.contextsetup.createcontextcontainer.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ContextSetup](./kibana-plugin-public.contextsetup.md) > [createContextContainer](./kibana-plugin-public.contextsetup.createcontextcontainer.md) + +## ContextSetup.createContextContainer() method + +Creates a new [ContextContainer](./kibana-plugin-public.contextcontainer.md) for a service owner. + +Signature: + +```typescript +createContextContainer(): ContextContainer; +``` +Returns: + +`ContextContainer` + diff --git a/docs/development/core/public/kibana-plugin-public.contextsetup.md b/docs/development/core/public/kibana-plugin-public.contextsetup.md new file mode 100644 index 000000000000000..f1c8db5c8fd231f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.contextsetup.md @@ -0,0 +1,52 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ContextSetup](./kibana-plugin-public.contextsetup.md) + +## ContextSetup interface + +An object that handles registration of context providers and building of new context objects. + +Signature: + +```typescript +export interface ContextSetup +``` + +## Methods + +| Method | Description | +| --- | --- | +| [createContextContainer()](./kibana-plugin-public.contextsetup.createcontextcontainer.md) | Creates a new [ContextContainer](./kibana-plugin-public.contextcontainer.md) for a service owner. | + +## Remarks + +A `ContextContainer` can be used by any Core service or plugin (known as the "service owner") which wishes to expose APIs in a handler function. The container object will manage registering context providers and building a context object for a handler with all of the contexts that should be exposed to the handler's plugin. This is dependent on the dependencies that the handler's plugin declares. + +Contexts providers are executed in the order they were registered. Each provider gets access to context values provided by any plugins that it depends on. + +## Example + +How to create your own context + +```ts +class MyPlugin { + setup(core) { + this.myHandlers = new Map(); + this.contextContainer = core.createContextContainer(); + return { + registerContext: this.contextContainer.register, + registerHandler: (endpoint, handler) => + // `createHandler` must be called immediately. + this.myHandlers.set(endpoint, this.contextContainer.createHandler(handler)), + }; + } + + start() { + return { + registerContext: this.contextContainer.register, + }; + } +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.coresetup.context.md b/docs/development/core/public/kibana-plugin-public.coresetup.context.md new file mode 100644 index 000000000000000..dbb1c6d127a505b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.coresetup.context.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [CoreSetup](./kibana-plugin-public.coresetup.md) > [context](./kibana-plugin-public.coresetup.context.md) + +## CoreSetup.context property + +[ContextSetup](./kibana-plugin-public.contextsetup.md) + +Signature: + +```typescript +context: Pick; +``` diff --git a/docs/development/core/public/kibana-plugin-public.coresetup.md b/docs/development/core/public/kibana-plugin-public.coresetup.md index 5bbd54a2561a31d..529a36c6e73683f 100644 --- a/docs/development/core/public/kibana-plugin-public.coresetup.md +++ b/docs/development/core/public/kibana-plugin-public.coresetup.md @@ -16,6 +16,7 @@ export interface CoreSetup | Property | Type | Description | | --- | --- | --- | +| [context](./kibana-plugin-public.coresetup.context.md) | Pick<ContextSetup, 'createContextContainer'> | [ContextSetup](./kibana-plugin-public.contextsetup.md) | | [fatalErrors](./kibana-plugin-public.coresetup.fatalerrors.md) | FatalErrorsSetup | [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | | [http](./kibana-plugin-public.coresetup.http.md) | HttpSetup | [HttpSetup](./kibana-plugin-public.httpsetup.md) | | [notifications](./kibana-plugin-public.coresetup.notifications.md) | NotificationsSetup | [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | diff --git a/docs/development/core/public/kibana-plugin-public.handler.md b/docs/development/core/public/kibana-plugin-public.handler.md new file mode 100644 index 000000000000000..856d2d580757369 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.handler.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [Handler](./kibana-plugin-public.handler.md) + +## Handler type + +A function registered by a plugin to perform some action. + +Signature: + +```typescript +export declare type Handler = (context: TContext, ...rest: THandlerParameters) => TReturn; +``` + +## Remarks + +A new `TContext` will be built for each handler before invoking. + diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index 98b6a8703f54358..15e300e689d2d6b 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -34,6 +34,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeRecentlyAccessed](./kibana-plugin-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-public.chromerecentlyaccessed.md) for recently accessed history. | | [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-public.chromerecentlyaccessedhistoryitem.md) | | | [ChromeStart](./kibana-plugin-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | +| [ContextContainer](./kibana-plugin-public.contextcontainer.md) | An object that handles registration of context providers and building of new context objects. | +| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and building of new context objects. | | [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | | [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | @@ -58,6 +60,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | --- | --- | | [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | | [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | +| [ContextProvider](./kibana-plugin-public.contextprovider.md) | A function that returns a context value for a specific key of given context type. | +| [Handler](./kibana-plugin-public.handler.md) | A function registered by a plugin to perform some action. | | [HttpSetup](./kibana-plugin-public.httpsetup.md) | | | [HttpStart](./kibana-plugin-public.httpstart.md) | | | [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | diff --git a/src/core/public/context/context.mock.ts b/src/core/public/context/context.mock.ts new file mode 100644 index 000000000000000..3ff6ea280276345 --- /dev/null +++ b/src/core/public/context/context.mock.ts @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 { ContextContainerImplementation } from './context'; + +export type ContextContainerMock = jest.Mocked< + PublicMethodsOf> +>; + +const createContextMock = () => { + const contextMock: ContextContainerMock = { + registerContext: jest.fn(), + createHandler: jest.fn(), + setCurrentPlugin: jest.fn(), + }; + return contextMock; +}; + +export const contextMock = { + create: createContextMock, +}; diff --git a/src/core/public/context/context.test.ts b/src/core/public/context/context.test.ts new file mode 100644 index 000000000000000..8027acd7aa19608 --- /dev/null +++ b/src/core/public/context/context.test.ts @@ -0,0 +1,212 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 { PluginName } from '../../server'; +import { ContextContainerImplementation } from './context'; + +const plugins: ReadonlyMap = new Map([ + ['pluginA', []], + ['pluginB', ['pluginA']], + ['pluginC', ['pluginA', 'pluginB']], + ['pluginD', []], +]); + +interface MyContext { + core1: string; + core2: number; + ctxFromA: string; + ctxFromB: number; + ctxFromC: boolean; + ctxFromD: object; + baseCtx: number; +} + +describe('ContextContainer', () => { + it('does not allow the same context to be registered twice', () => { + const contextContainer = new ContextContainerImplementation(plugins); + contextContainer.registerContext('ctxFromA', () => 'aString'); + + expect(() => + contextContainer.registerContext('ctxFromA', () => 'aString') + ).toThrowErrorMatchingInlineSnapshot( + `"Context provider for ctxFromA has already been registered."` + ); + }); + + describe('context building', () => { + it('resolves dependencies', async () => { + const contextContainer = new ContextContainerImplementation(plugins); + expect.assertions(8); + contextContainer.registerContext('core1', context => { + expect(context).toEqual({}); + return 'core'; + }); + + contextContainer.setCurrentPlugin('pluginA'); + contextContainer.registerContext('ctxFromA', context => { + expect(context).toEqual({ core1: 'core' }); + return 'aString'; + }); + contextContainer.setCurrentPlugin('pluginB'); + contextContainer.registerContext('ctxFromB', context => { + expect(context).toEqual({ core1: 'core', ctxFromA: 'aString' }); + return 299; + }); + contextContainer.setCurrentPlugin('pluginC'); + contextContainer.registerContext('ctxFromC', context => { + expect(context).toEqual({ core1: 'core', ctxFromA: 'aString', ctxFromB: 299 }); + return false; + }); + contextContainer.setCurrentPlugin('pluginD'); + contextContainer.registerContext('ctxFromD', context => { + expect(context).toEqual({ core1: 'core' }); + return {}; + }); + + contextContainer.setCurrentPlugin('pluginC'); + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(rawHandler1); + + contextContainer.setCurrentPlugin('pluginD'); + const rawHandler2 = jest.fn(() => 'handler2'); + const handler2 = contextContainer.createHandler(rawHandler2); + + contextContainer.setCurrentPlugin(undefined); + + await handler1(); + await handler2(); + + // Should have context from pluginC, its deps, and core + expect(rawHandler1).toHaveBeenCalledWith({ + core1: 'core', + ctxFromA: 'aString', + ctxFromB: 299, + ctxFromC: false, + }); + + // Should have context from pluginD, and core + expect(rawHandler2).toHaveBeenCalledWith({ + core1: 'core', + ctxFromD: {}, + }); + }); + + it('exposes all previously registered context to Core providers', async () => { + expect.assertions(4); + const contextContainer = new ContextContainerImplementation(plugins); + + contextContainer + .registerContext('core1', context => { + expect(context).toEqual({}); + return 'core'; + }) + .registerContext('core2', context => { + expect(context).toEqual({ core1: 'core' }); + return 101; + }); + + contextContainer.setCurrentPlugin('pluginA'); + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(rawHandler1); + + expect(await handler1()).toEqual('handler1'); + + // If no context is registered for pluginA, only core contexts should be exposed + expect(rawHandler1).toHaveBeenCalledWith({ + core1: 'core', + core2: 101, + }); + }); + + it('passes additional arguments to providers', async () => { + expect.assertions(6); + const contextContainer = new ContextContainerImplementation< + MyContext, + string, + [string, number] + >(plugins); + + contextContainer.registerContext('core1', (context, str, num) => { + expect(str).toEqual('passed string'); + expect(num).toEqual(77); + return `core ${str}`; + }); + + contextContainer.setCurrentPlugin('pluginD'); + contextContainer.registerContext('ctxFromD', (context, str, num) => { + expect(str).toEqual('passed string'); + expect(num).toEqual(77); + return { + num: 77, + }; + }); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(rawHandler1); + + expect(await handler1('passed string', 77)).toEqual('handler1'); + + expect(rawHandler1).toHaveBeenCalledWith( + { + core1: 'core passed string', + ctxFromD: { + num: 77, + }, + }, + 'passed string', + 77 + ); + }); + }); + + describe('createHandler', () => { + it('throws error if registered outside plugin', async () => { + const contextContainer = new ContextContainerImplementation(plugins); + contextContainer.setCurrentPlugin(undefined); + await expect(() => + contextContainer.createHandler(jest.fn()) + ).toThrowErrorMatchingInlineSnapshot(`"Cannot create handlers outside a plugin!"`); + }); + + it('returns value from original handler', async () => { + const contextContainer = new ContextContainerImplementation(plugins); + + contextContainer.setCurrentPlugin('pluginA'); + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(rawHandler1); + + expect(await handler1()).toEqual('handler1'); + }); + + it('passes additional arguments to handlers', async () => { + const contextContainer = new ContextContainerImplementation< + MyContext, + string, + [string, number] + >(plugins); + + contextContainer.setCurrentPlugin('pluginA'); + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(rawHandler1); + + await handler1('passed string', 77); + expect(rawHandler1).toHaveBeenCalledWith({}, 'passed string', 77); + }); + }); +}); diff --git a/src/core/public/context/context.ts b/src/core/public/context/context.ts new file mode 100644 index 000000000000000..669f70ffeacb95d --- /dev/null +++ b/src/core/public/context/context.ts @@ -0,0 +1,222 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 { flatten } from 'lodash'; +import { PluginName } from '../../server'; +import { pick } from '../../utils'; + +/** + * A function that returns a context value for a specific key of given context type. + * + * @remarks + * This function will be called each time a new context is built for a handler invocation. + * + * @param context - A partial context object containing only the keys for values provided by plugin dependencies + * @param rest - Additional parameters provided by the service owner of this context + * @returns The context value associated with this key. May also return a Promise. + * + * @public + */ +export type ContextProvider< + TContext extends {}, + TContextName extends keyof TContext, + TProviderParameters extends any[] = [] +> = ( + context: Partial, + ...rest: TProviderParameters +) => Promise | TContext[TContextName]; + +/** + * A function registered by a plugin to perform some action. + * + * @remarks + * A new `TContext` will be built for each handler before invoking. + * + * @public + */ +export type Handler = ( + context: TContext, + ...rest: THandlerParameters +) => TReturn; + +type Promisify = T extends Promise ? Promise : Promise; + +/** + * An object that handles registration of context providers and building of new context objects. + * + * @remarks + * A `ContextContainer` can be used by any Core service or plugin (known as the "service owner") which wishes to expose + * APIs in a handler function. The container object will manage registering context providers and building a context + * object for a handler with all of the contexts that should be exposed to the handler's plugin. This is dependent on + * the dependencies that the handler's plugin declares. + * + * Contexts providers are executed in the order they were registered. Each provider gets access to context values + * provided by any plugins that it depends on. + * + * + * @public + */ +export interface ContextContainer< + TContext extends {}, + THandlerReturn, + THandlerParameters extends any[] = [] +> { + /** + * Register a new context provider. Throws an exception if more than one provider is registered for the same context + * key. + * + * @param contextName - The key of the `TContext` object this provider supplies the value for. + * @param provider - A {@link ContextProvider} to be called each time a new context is created. + * @returns The `ContextContainer` for method chaining. + */ + registerContext( + contextName: TContextName, + provider: ContextProvider + ): this; + + /** + * Create a new handler function pre-wired to context for the plugin. + * + * @remarks + * This must be called when the handler is registered by the consuming plugin. If this is called later in the + * lifecycle it will throw an exception. + * + * @param handler + */ + createHandler( + handler: Handler + ): (...rest: THandlerParameters) => Promisify; +} + +/** @internal */ +export class ContextContainerImplementation< + TContext extends {}, + THandlerReturn, + THandlerParameters extends any[] = [] +> implements ContextContainer { + private currentPlugin?: string; + + /** + * Used to map contexts to their providers and associated plugin. In registration order which is tightly coupled to + * plugin load order. + */ + private readonly contextProviders = new Map< + keyof TContext, + { + provider: ContextProvider; + plugin?: PluginName; + } + >(); + /** Used to keep track of which plugins registered which contexts for dependency resolution. */ + private readonly contextNamesByPlugin = new Map>(); + + /** + * @param pluginDependencies - A map of plugins to an array of their dependencies. + */ + constructor(private readonly pluginDependencies: ReadonlyMap) {} + + public registerContext = ( + contextName: TContextName, + provider: ContextProvider + ): this => { + if (this.contextProviders.has(contextName)) { + throw new Error(`Context provider for ${contextName} has already been registered.`); + } + + const plugin = this.currentPlugin; + this.contextProviders.set(contextName, { provider, plugin }); + + if (plugin) { + this.contextNamesByPlugin.set(plugin, [ + ...(this.contextNamesByPlugin.get(plugin) || []), + contextName, + ]); + } + + return this; + }; + + public createHandler = (handler: Handler) => { + const plugin = this.currentPlugin; + if (!plugin) { + throw new Error(`Cannot create handlers outside a plugin!`); + } else if (!this.pluginDependencies.has(plugin)) { + throw new Error(`Cannot create handler for unknown plugin: ${plugin}`); + } + + return (async (...args: THandlerParameters) => { + const context = await this.buildContext(plugin, {}, ...args); + return handler(context, ...args); + }) as (...args: THandlerParameters) => Promisify; + }; + + private async buildContext( + pluginName: PluginName, + baseContext: Partial = {}, + ...contextArgs: THandlerParameters + ): Promise { + const ownerContextNames = [...this.contextProviders] + .filter(([name, { plugin }]) => plugin === undefined) + .map(([name]) => name); + const contextNamesForPlugin = (plug: PluginName): Set => { + const pluginDeps = this.pluginDependencies.get(plug); + if (!pluginDeps) { + // This should be impossible, but just in case. + throw new Error(`Cannot create context for unknown plugin: ${pluginName}`); + } + + return new Set([ + // Owner contexts + ...ownerContextNames, + // Contexts calling plugin created + ...(this.contextNamesByPlugin.get(pluginName) || []), + // Contexts calling plugin's dependencies created + ...flatten(pluginDeps.map(p => this.contextNamesByPlugin.get(p) || [])), + ]); + }; + + const contextsToBuild = contextNamesForPlugin(pluginName); + + return [...this.contextProviders] + .filter(([contextName]) => contextsToBuild.has(contextName)) + .reduce( + async (contextPromise, [contextName, { provider, plugin }]) => { + const resolvedContext = await contextPromise; + + // If the provider is not from a plugin, give access to the entire + // context built so far (this is only possible for providers registered + // by the service owner). + const exposedContext = plugin + ? pick(resolvedContext, [...contextNamesForPlugin(plugin)]) + : resolvedContext; + + return { + ...resolvedContext, + [contextName]: await provider(exposedContext as Partial, ...contextArgs), + }; + }, + Promise.resolve(baseContext) as Promise + ); + } + + /** @internal */ + public setCurrentPlugin(plugin?: string) { + this.currentPlugin = plugin; + } +} diff --git a/src/core/public/context/context_service.mock.ts b/src/core/public/context/context_service.mock.ts new file mode 100644 index 000000000000000..fae14cd1ccae6c3 --- /dev/null +++ b/src/core/public/context/context_service.mock.ts @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 { ContextStart, ContextService, ContextSetup } from './context_service'; +import { contextMock } from './context.mock'; + +const createSetupContractMock = () => { + const setupContract: jest.Mocked = { + createContextContainer: jest.fn().mockImplementation(() => contextMock.create()), + setCurrentPlugin: jest.fn(), + }; + return setupContract; +}; + +const createStartContractMock = () => { + const startContract: jest.Mocked = { + setCurrentPlugin: jest.fn(), + }; + return startContract; +}; + +type ContextServiceContract = PublicMethodsOf; +const createMock = () => { + const mocked: jest.Mocked = { + setup: jest.fn(), + start: jest.fn(), + }; + mocked.setup.mockReturnValue(createSetupContractMock()); + mocked.start.mockReturnValue(createStartContractMock()); + return mocked; +}; + +export const contextServiceMock = { + create: createMock, + createSetupContract: createSetupContractMock, + createStartContract: createStartContractMock, +}; diff --git a/src/core/public/context/context_service.test.mocks.ts b/src/core/public/context/context_service.test.mocks.ts new file mode 100644 index 000000000000000..d0bcb201b11ce55 --- /dev/null +++ b/src/core/public/context/context_service.test.mocks.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 { contextMock } from './context.mock'; + +export const MockContextConstructor = jest.fn(contextMock.create); +jest.doMock('./context', () => ({ + ContextContainerImplementation: MockContextConstructor, +})); diff --git a/src/core/public/context/context_service.test.ts b/src/core/public/context/context_service.test.ts new file mode 100644 index 000000000000000..467341083279a21 --- /dev/null +++ b/src/core/public/context/context_service.test.ts @@ -0,0 +1,104 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 { ContextContainerImplementation } from './context'; + +import { MockContextConstructor } from './context_service.test.mocks'; +import { ContextService } from './context_service'; +import { ContextContainerMock } from './context.mock'; + +const pluginDependencies = new Map([ + ['pluginA', []], + ['pluginB', ['pluginA']], + ['pluginC', ['pluginB']], +]); + +describe('ContextService', () => { + describe('#setup()', () => { + test('createContextContainer returns a new container configured with pluginDependencies', () => { + const service = new ContextService(); + const setup = service.setup({ pluginDependencies }); + expect(setup.createContextContainer()).toBeDefined(); + expect(MockContextConstructor).toHaveBeenCalledWith(pluginDependencies); + }); + + test('setCurrentPlugin does not fail if there are no contianers', () => { + const service = new ContextService(); + const setup = service.setup({ pluginDependencies }); + expect(() => setup.setCurrentPlugin('pluginA')).not.toThrow(); + }); + + test('setCurrentPlugin calls on all context containers', () => { + const service = new ContextService(); + const setup = service.setup({ pluginDependencies }); + const container1 = setup.createContextContainer() as ContextContainerMock; + const container2 = setup.createContextContainer() as ContextContainerMock; + const container3 = setup.createContextContainer() as ContextContainerMock; + + setup.setCurrentPlugin('pluginA'); + expect(container1.setCurrentPlugin).toHaveBeenCalledWith('pluginA'); + expect(container2.setCurrentPlugin).toHaveBeenCalledWith('pluginA'); + expect(container3.setCurrentPlugin).toHaveBeenCalledWith('pluginA'); + + setup.setCurrentPlugin('pluginB'); + expect(container1.setCurrentPlugin).toHaveBeenCalledWith('pluginB'); + expect(container2.setCurrentPlugin).toHaveBeenCalledWith('pluginB'); + expect(container3.setCurrentPlugin).toHaveBeenCalledWith('pluginB'); + + setup.setCurrentPlugin(undefined); + expect(container1.setCurrentPlugin).toHaveBeenCalledWith(undefined); + expect(container2.setCurrentPlugin).toHaveBeenCalledWith(undefined); + expect(container3.setCurrentPlugin).toHaveBeenCalledWith(undefined); + }); + }); + + describe('#start()', () => { + test('setCurrentPlugin does not fail if there are no contianers', () => { + const service = new ContextService(); + service.setup({ pluginDependencies }); + const start = service.start(); + expect(() => start.setCurrentPlugin('pluginA')).not.toThrow(); + }); + + test('setCurrentPlugin calls on all context containers', () => { + const service = new ContextService(); + const setup = service.setup({ pluginDependencies }); + const container1 = setup.createContextContainer() as ContextContainerMock; + const container2 = setup.createContextContainer() as ContextContainerMock; + const container3 = setup.createContextContainer() as ContextContainerMock; + + const start = service.start(); + + start.setCurrentPlugin('pluginA'); + expect(container1.setCurrentPlugin).toHaveBeenCalledWith('pluginA'); + expect(container2.setCurrentPlugin).toHaveBeenCalledWith('pluginA'); + expect(container3.setCurrentPlugin).toHaveBeenCalledWith('pluginA'); + + start.setCurrentPlugin('pluginB'); + expect(container1.setCurrentPlugin).toHaveBeenCalledWith('pluginB'); + expect(container2.setCurrentPlugin).toHaveBeenCalledWith('pluginB'); + expect(container3.setCurrentPlugin).toHaveBeenCalledWith('pluginB'); + + start.setCurrentPlugin(undefined); + expect(container1.setCurrentPlugin).toHaveBeenCalledWith(undefined); + expect(container2.setCurrentPlugin).toHaveBeenCalledWith(undefined); + expect(container3.setCurrentPlugin).toHaveBeenCalledWith(undefined); + }); + }); +}); diff --git a/src/core/public/context/context_service.ts b/src/core/public/context/context_service.ts new file mode 100644 index 000000000000000..c88198bb7b27110 --- /dev/null +++ b/src/core/public/context/context_service.ts @@ -0,0 +1,112 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 { ContextContainer, ContextContainerImplementation } from './context'; + +interface StartDeps { + pluginDependencies: ReadonlyMap; +} + +/** @internal */ +export class ContextService { + private readonly containers = new Set>(); + + public setup({ pluginDependencies }: StartDeps): ContextSetup { + return { + setCurrentPlugin: this.setCurrentPlugin.bind(this), + createContextContainer: < + TContext extends {}, + THandlerReturn, + THandlerParameters extends any[] = [] + >() => { + const newContainer = new ContextContainerImplementation< + TContext, + THandlerReturn, + THandlerParameters + >(pluginDependencies); + + this.containers.add(newContainer); + return newContainer; + }, + }; + } + + public start(): ContextStart { + return { + setCurrentPlugin: this.setCurrentPlugin.bind(this), + }; + } + + private setCurrentPlugin(plugin?: string) { + [...this.containers].forEach(container => container.setCurrentPlugin(plugin)); + } +} + +/** + * {@inheritdoc ContextContainer} + * + * @example + * How to create your own context + * ```ts + * class MyPlugin { + * setup(core) { + * this.myHandlers = new Map(); + * this.contextContainer = core.createContextContainer(); + * return { + * registerContext: this.contextContainer.register, + * registerHandler: (endpoint, handler) => + * // `createHandler` must be called immediately. + * this.myHandlers.set(endpoint, this.contextContainer.createHandler(handler)), + * }; + * } + * + * start() { + * return { + * registerContext: this.contextContainer.register, + * }; + * } + * } + * ``` + * + * @public + */ +export interface ContextSetup { + /** + * Must be called by the PluginsService during each plugin's lifecycle methods. + * @internal + */ + setCurrentPlugin(plugin?: string): void; + + /** + * Creates a new {@link ContextContainer} for a service owner. + */ + createContextContainer< + TContext extends {}, + THandlerReturn, + THandlerParmaters extends any[] = [] + >(): ContextContainer; +} + +/** @internal */ +export interface ContextStart { + /** + * @internal + */ + setCurrentPlugin(plugin?: string): void; +} diff --git a/src/core/public/context/index.ts b/src/core/public/context/index.ts new file mode 100644 index 000000000000000..270d8563cad8e08 --- /dev/null +++ b/src/core/public/context/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you 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 { ContextService, ContextSetup, ContextStart } from './context_service'; +export { ContextContainer, ContextProvider, Handler } from './context'; diff --git a/src/core/public/core_system.test.mocks.ts b/src/core/public/core_system.test.mocks.ts index 4a96214b3e5defd..d2494badfacdb56 100644 --- a/src/core/public/core_system.test.mocks.ts +++ b/src/core/public/core_system.test.mocks.ts @@ -30,6 +30,7 @@ import { pluginsServiceMock } from './plugins/plugins_service.mock'; import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { renderingServiceMock } from './rendering/rendering_service.mock'; +import { contextServiceMock } from './context/context_service.mock'; export const MockLegacyPlatformService = legacyPlatformServiceMock.create(); export const LegacyPlatformServiceConstructor = jest @@ -120,3 +121,9 @@ export const RenderingServiceConstructor = jest.fn().mockImplementation(() => Mo jest.doMock('./rendering', () => ({ RenderingService: RenderingServiceConstructor, })); + +export const MockContextService = contextServiceMock.create(); +export const ContextServiceConstructor = jest.fn().mockImplementation(() => MockContextService); +jest.doMock('./context', () => ({ + ContextService: ContextServiceConstructor, +})); diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 044a40b27599364..ca26b6fe45dbadf 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -41,6 +41,7 @@ import { MockDocLinksService, MockRenderingService, RenderingServiceConstructor, + MockContextService, } from './core_system.test.mocks'; import { CoreSystem } from './core_system'; @@ -216,6 +217,11 @@ describe('#start()', () => { expect(MockApplicationService.start).toHaveBeenCalledTimes(1); }); + it('calls context#start()', async () => { + await startCore(); + expect(MockContextService.start).toHaveBeenCalledTimes(1); + }); + it('calls docLinks#start()', async () => { await startCore(); expect(MockDocLinksService.start).toHaveBeenCalledTimes(1); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index f3f466df8a78e4c..d8de15d73b1cc43 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -34,6 +34,7 @@ import { ApplicationService } from './application'; import { mapToObject } from '../utils/'; import { DocLinksService } from './doc_links'; import { RenderingService } from './rendering'; +import { ContextService } from './context'; interface Params { rootDomElement: HTMLElement; @@ -69,6 +70,7 @@ export class CoreSystem { private readonly application: ApplicationService; private readonly docLinks: DocLinksService; private readonly rendering: RenderingService; + private readonly context: ContextService; private readonly rootDomElement: HTMLElement; private fatalErrorsSetup: FatalErrorsSetup | null = null; @@ -103,6 +105,7 @@ export class CoreSystem { this.chrome = new ChromeService({ browserSupportsCsp }); this.docLinks = new DocLinksService(); this.rendering = new RenderingService(); + this.context = new ContextService(); const core: CoreContext = {}; this.plugins = new PluginsService(core); @@ -127,8 +130,12 @@ export class CoreSystem { const notifications = this.notifications.setup({ uiSettings }); const application = this.application.setup(); + const pluginDependencies = this.plugins.setPluginDependencies(injectedMetadata.getPlugins()); + const context = this.context.setup({ pluginDependencies }); + const core: InternalCoreSetup = { application, + context, fatalErrors: this.fatalErrorsSetup, http, injectedMetadata, @@ -159,6 +166,7 @@ export class CoreSystem { const http = await this.http.start({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); const i18n = await this.i18n.start(); const application = await this.application.start({ injectedMetadata }); + const context = await this.context.start(); const coreUiTargetDomElement = document.createElement('div'); coreUiTargetDomElement.id = 'kibana-body'; @@ -190,6 +198,7 @@ export class CoreSystem { const core: InternalCoreStart = { application, chrome, + context, docLinks, http, i18n, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 2a88ebf86ab0c55..66b69bbc289778e 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -66,6 +66,7 @@ import { Plugin, PluginInitializer, PluginInitializerContext } from './plugins'; import { UiSettingsClient, UiSettingsState, UiSettingsClientContract } from './ui_settings'; import { ApplicationSetup, Capabilities, ApplicationStart } from './application'; import { DocLinksStart } from './doc_links'; +import { ContextContainer, ContextProvider, ContextSetup, ContextStart, Handler } from './context'; export { CoreContext, CoreSystem } from './core_system'; export { RecursiveReadonly } from '../utils'; @@ -80,6 +81,8 @@ export { RecursiveReadonly } from '../utils'; * https://github.com/Microsoft/web-build-tools/issues/1237 */ export interface CoreSetup { + /** {@link ContextSetup} */ + context: Pick; /** {@link FatalErrorsSetup} */ fatalErrors: FatalErrorsSetup; /** {@link HttpSetup} */ @@ -121,12 +124,14 @@ export interface CoreStart { /** @internal */ export interface InternalCoreSetup extends CoreSetup { application: ApplicationSetup; + context: ContextSetup; injectedMetadata: InjectedMetadataSetup; } /** @internal */ export interface InternalCoreStart extends CoreStart { application: ApplicationStart; + context: ContextStart; injectedMetadata: InjectedMetadataStart; } @@ -146,10 +151,14 @@ export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, ChromeStart, + ContextContainer, + ContextProvider, + ContextSetup, DocLinksStart, ErrorToastOptions, FatalErrorInfo, FatalErrorsSetup, + Handler, HttpInterceptor, HttpServiceBase, HttpSetup, diff --git a/src/core/public/legacy/legacy_service.test.ts b/src/core/public/legacy/legacy_service.test.ts index a514bf71540550e..76a05f8d03ae712 100644 --- a/src/core/public/legacy/legacy_service.test.ts +++ b/src/core/public/legacy/legacy_service.test.ts @@ -58,8 +58,10 @@ import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { LegacyPlatformService } from './legacy_service'; import { applicationServiceMock } from '../application/application_service.mock'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; +import { contextServiceMock } from '../context/context_service.mock'; const applicationSetup = applicationServiceMock.createSetupContract(); +const contextSetup = contextServiceMock.createSetupContract(); const fatalErrorsSetup = fatalErrorsServiceMock.createSetupContract(); const httpSetup = httpServiceMock.createSetupContract(); const injectedMetadataSetup = injectedMetadataServiceMock.createSetupContract(); @@ -75,6 +77,7 @@ const defaultParams = { const defaultSetupDeps = { core: { application: applicationSetup, + context: contextSetup, fatalErrors: fatalErrorsSetup, injectedMetadata: injectedMetadataSetup, notifications: notificationsSetup, @@ -85,6 +88,7 @@ const defaultSetupDeps = { }; const applicationStart = applicationServiceMock.createStartContract(); +const contextStart = contextServiceMock.createStartContract(); const docLinksStart = docLinksServiceMock.createStartContract(); const httpStart = httpServiceMock.createStartContract(); const chromeStart = chromeServiceMock.createStartContract(); @@ -97,6 +101,7 @@ const uiSettingsStart = uiSettingsServiceMock.createStartContract(); const defaultStartDeps = { core: { application: applicationStart, + context: contextStart, docLinks: docLinksStart, http: httpStart, chrome: chromeStart, diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index b1312eaa228d214..3682d86168dcd2d 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -26,6 +26,7 @@ import { i18nServiceMock } from './i18n/i18n_service.mock'; import { notificationServiceMock } from './notifications/notifications_service.mock'; import { overlayServiceMock } from './overlays/overlay_service.mock'; import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; +import { contextServiceMock } from './context/context_service.mock'; export { chromeServiceMock } from './chrome/chrome_service.mock'; export { docLinksServiceMock } from './doc_links/doc_links_service.mock'; @@ -40,6 +41,7 @@ export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; function createCoreSetupMock() { const mock: MockedKeys = { + context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(), notifications: notificationServiceMock.createSetupContract(), diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index bc77b139a86dc50..557b79b6931a207 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -69,8 +69,9 @@ export function createPluginSetupContext< plugin: PluginWrapper ): CoreSetup { return { - http: deps.http, + context: omit(deps.context, 'setCurrentPlugin'), fatalErrors: deps.fatalErrors, + http: deps.http, notifications: deps.notifications, uiSettings: deps.uiSettings, }; diff --git a/src/core/public/plugins/plugins_service.mock.ts b/src/core/public/plugins/plugins_service.mock.ts index 4df57b05fda30b9..baadfd1336912d1 100644 --- a/src/core/public/plugins/plugins_service.mock.ts +++ b/src/core/public/plugins/plugins_service.mock.ts @@ -38,6 +38,7 @@ const createStartContractMock = () => { type PluginsServiceContract = PublicMethodsOf; const createMock = () => { const mocked: jest.Mocked = { + setPluginDependencies: jest.fn(), setup: jest.fn(), start: jest.fn(), stop: jest.fn(), diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 55e91bde27cb09b..ef8ecd9348ca819 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -43,6 +43,7 @@ import { injectedMetadataServiceMock } from '../injected_metadata/injected_metad import { httpServiceMock } from '../http/http_service.mock'; import { CoreSetup, CoreStart } from '..'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; +import { contextServiceMock } from '../context/context_service.mock'; export let mockPluginInitializers: Map; @@ -61,6 +62,9 @@ let mockStartContext: DeeplyMocked; beforeEach(() => { mockSetupDeps = { application: applicationServiceMock.createSetupContract(), + context: contextServiceMock.createSetupContract(), + fatalErrors: fatalErrorsServiceMock.createSetupContract(), + http: httpServiceMock.createSetupContract(), injectedMetadata: (function() { const metadata = injectedMetadataServiceMock.createSetupContract(); metadata.getPlugins.mockReturnValue([ @@ -73,14 +77,16 @@ beforeEach(() => { ]); return metadata; })(), - fatalErrors: fatalErrorsServiceMock.createSetupContract(), - http: httpServiceMock.createSetupContract(), notifications: notificationServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), }; - mockSetupContext = omit(mockSetupDeps, 'application', 'injectedMetadata'); + mockSetupContext = { + ...omit(mockSetupDeps, 'application', 'injectedMetadata'), + context: omit(mockSetupDeps.context, 'setCurrentPlugin'), + }; mockStartDeps = { application: applicationServiceMock.createStartContract(), + context: contextServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), http: httpServiceMock.createStartContract(), chrome: chromeServiceMock.createStartContract(), @@ -91,7 +97,7 @@ beforeEach(() => { uiSettings: uiSettingsServiceMock.createStartContract(), }; mockStartContext = { - ...omit(mockStartDeps, 'injectedMetadata'), + ...omit(mockStartDeps, 'context', 'injectedMetadata'), application: { capabilities: mockStartDeps.application.capabilities, }, diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts index 03725a9d7f88394..94ba4ce9bc9e858 100644 --- a/src/core/public/plugins/plugins_service.ts +++ b/src/core/public/plugins/plugins_service.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PluginName } from '../../server'; +import { DiscoveredPlugin, PluginName } from '../../server'; import { CoreService } from '../../types'; import { CoreContext } from '../core_system'; import { PluginWrapper } from './plugin'; @@ -35,11 +35,11 @@ export type PluginsServiceStartDeps = InternalCoreStart; /** @internal */ export interface PluginsServiceSetup { - contracts: Map; + contracts: ReadonlyMap; } /** @internal */ export interface PluginsServiceStart { - contracts: Map; + contracts: ReadonlyMap; } /** @@ -50,15 +50,28 @@ export interface PluginsServiceStart { */ export class PluginsService implements CoreService { /** Plugin wrappers in topological order. */ - private readonly plugins: Map< - PluginName, - PluginWrapper> - > = new Map(); + private readonly plugins = new Map>>(); + private readonly pluginDependencies = new Map(); + private readonly satupPlugins: PluginName[] = []; constructor(private readonly coreContext: CoreContext) {} - public async setup(deps: PluginsServiceSetupDeps) { + public setPluginDependencies(plugins: Array<{ id: PluginName; plugin: DiscoveredPlugin }>) { + // Setup map of dependencies + const allPluginNames = new Set(plugins.map(p => p.id)); + plugins.forEach(({ id, plugin }) => + this.pluginDependencies.set(id, [ + ...plugin.requiredPlugins, + ...plugin.optionalPlugins.filter(optPlugin => allPluginNames.has(optPlugin)), + ]) + ); + + return this.pluginDependencies; + } + + public async setup(deps: PluginsServiceSetupDeps): Promise { + this.setPluginDependencies(deps.injectedMetadata.getPlugins()); // Construct plugin wrappers, depending on the topological order set by the server. deps.injectedMetadata .getPlugins() @@ -75,12 +88,10 @@ export class PluginsService implements CoreService(); for (const [pluginName, plugin] of this.plugins.entries()) { - const pluginDeps = new Set([ - ...plugin.requiredPlugins, - ...plugin.optionalPlugins.filter(optPlugin => this.plugins.get(optPlugin)), - ]); + // Set global context variable for current plugin setting up + deps.context.setCurrentPlugin(pluginName); - const pluginDepContracts = [...pluginDeps.keys()].reduce( + const pluginDepContracts = [...this.pluginDependencies.get(pluginName)!].reduce( (depContracts, dependencyName) => { // Only set if present. Could be absent if plugin does not have client-side code or is a // missing optional plugin. @@ -104,20 +115,21 @@ export class PluginsService implements CoreService { // Setup each plugin with required and optional plugin contracts const contracts = new Map(); for (const [pluginName, plugin] of this.plugins.entries()) { - const pluginDeps = new Set([ - ...plugin.requiredPlugins, - ...plugin.optionalPlugins.filter(optPlugin => this.plugins.get(optPlugin)), - ]); + // Set global context variable for current plugin setting up + deps.context.setCurrentPlugin(pluginName); - const pluginDepContracts = [...pluginDeps.keys()].reduce( + const pluginDepContracts = [...this.pluginDependencies.get(pluginName)!].reduce( (depContracts, dependencyName) => { // Only set if present. Could be absent if plugin does not have client-side code or is a // missing optional plugin. @@ -139,6 +151,9 @@ export class PluginsService implements CoreService { + // Warning: (ae-forgotten-export) The symbol "Promisify" needs to be exported by the entry point index.d.ts + createHandler(handler: Handler): (...rest: THandlerParameters) => Promisify; + registerContext(contextName: TContextName, provider: ContextProvider): this; +} + +// @public +export type ContextProvider = (context: Partial, ...rest: TProviderParameters) => Promise | TContext[TContextName]; + +// @public +export interface ContextSetup { + createContextContainer(): ContextContainer; + // @internal + setCurrentPlugin(plugin?: string): void; +} + // @internal (undocumented) export interface CoreContext { } // @public export interface CoreSetup { + // (undocumented) + context: Pick; // (undocumented) fatalErrors: FatalErrorsSetup; // (undocumented) @@ -329,6 +348,9 @@ export interface FatalErrorsSetup { get$: () => Rx.Observable; } +// @public +export type Handler = (context: TContext, ...rest: THandlerParameters) => TReturn; + // @public (undocumented) export interface HttpInterceptor { // Warning: (ae-forgotten-export) The symbol "HttpInterceptController" needs to be exported by the entry point index.d.ts @@ -404,6 +426,8 @@ export interface I18nStart { export interface InternalCoreSetup extends CoreSetup { // (undocumented) application: ApplicationSetup; + // (undocumented) + context: ContextSetup; // Warning: (ae-forgotten-export) The symbol "InjectedMetadataSetup" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -414,6 +438,10 @@ export interface InternalCoreSetup extends CoreSetup { export interface InternalCoreStart extends CoreStart { // (undocumented) application: ApplicationStart; + // Warning: (ae-forgotten-export) The symbol "ContextStart" needs to be exported by the entry point index.d.ts + // + // (undocumented) + context: ContextStart; // Warning: (ae-forgotten-export) The symbol "InjectedMetadataStart" needs to be exported by the entry point index.d.ts // // (undocumented) diff --git a/src/core/utils/map_to_object.ts b/src/core/utils/map_to_object.ts index bfbe5c8ab0beaf9..edb2fc2bcbfc72e 100644 --- a/src/core/utils/map_to_object.ts +++ b/src/core/utils/map_to_object.ts @@ -17,7 +17,7 @@ * under the License. */ -export function mapToObject(map: Map) { +export function mapToObject(map: ReadonlyMap) { const result: Record = Object.create(null); for (const [key, value] of map) { result[key] = value;