From 76b0fbebd2985e8d6d937e1a9c61bbf6acc9629b Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Tue, 30 Jul 2019 13:34:17 -0500 Subject: [PATCH] Add ContextService (#41251) --- ...lic.contextsetup.createcontextcontainer.md | 17 + .../kibana-plugin-public.contextsetup.md | 137 ++++++++ .../kibana-plugin-public.coresetup.context.md | 13 + .../public/kibana-plugin-public.coresetup.md | 1 + ...-public.icontextcontainer.createhandler.md | 27 ++ .../kibana-plugin-public.icontextcontainer.md | 80 +++++ ...ublic.icontextcontainer.registercontext.md | 34 ++ .../kibana-plugin-public.icontexthandler.md | 18 ++ .../kibana-plugin-public.icontextprovider.md | 18 ++ .../core/public/kibana-plugin-public.md | 4 + ...-plugin-public.plugininitializercontext.md | 7 + ...ublic.plugininitializercontext.opaqueid.md | 13 + src/core/public/context/context.mock.ts | 34 ++ src/core/public/context/context.test.ts | 232 ++++++++++++++ src/core/public/context/context.ts | 293 ++++++++++++++++++ .../public/context/context_service.mock.ts | 42 +++ .../context/context_service.test.mocks.ts | 25 ++ .../public/context/context_service.test.ts | 36 +++ src/core/public/context/context_service.ts | 117 +++++++ src/core/public/context/index.ts | 21 ++ src/core/public/core_system.test.mocks.ts | 7 + src/core/public/core_system.test.ts | 7 + src/core/public/core_system.ts | 19 +- src/core/public/index.ts | 7 + src/core/public/legacy/legacy_service.test.ts | 3 + src/core/public/mocks.ts | 2 + src/core/public/plugins/index.ts | 2 +- src/core/public/plugins/plugin.test.ts | 5 +- src/core/public/plugins/plugin.ts | 4 + src/core/public/plugins/plugin_context.ts | 18 +- .../public/plugins/plugins_service.mock.ts | 1 + .../public/plugins/plugins_service.test.ts | 86 ++--- src/core/public/plugins/plugins_service.ts | 74 +++-- src/core/public/public.api.md | 26 ++ src/core/utils/map_to_object.ts | 2 +- 35 files changed, 1353 insertions(+), 79 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-public.contextsetup.createcontextcontainer.md create mode 100644 docs/development/core/public/kibana-plugin-public.contextsetup.md create mode 100644 docs/development/core/public/kibana-plugin-public.coresetup.context.md create mode 100644 docs/development/core/public/kibana-plugin-public.icontextcontainer.createhandler.md create mode 100644 docs/development/core/public/kibana-plugin-public.icontextcontainer.md create mode 100644 docs/development/core/public/kibana-plugin-public.icontextcontainer.registercontext.md create mode 100644 docs/development/core/public/kibana-plugin-public.icontexthandler.md create mode 100644 docs/development/core/public/kibana-plugin-public.icontextprovider.md create mode 100644 docs/development/core/public/kibana-plugin-public.plugininitializercontext.opaqueid.md create mode 100644 src/core/public/context/context.mock.ts create mode 100644 src/core/public/context/context.test.ts create mode 100644 src/core/public/context/context.ts create mode 100644 src/core/public/context/context_service.mock.ts create mode 100644 src/core/public/context/context_service.test.mocks.ts create mode 100644 src/core/public/context/context_service.test.ts create mode 100644 src/core/public/context/context_service.ts create mode 100644 src/core/public/context/index.ts 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 00000000000000..2644596354e383 --- /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 [IContextContainer](./kibana-plugin-public.icontextcontainer.md) for a service owner. + +Signature: + +```typescript +createContextContainer(): IContextContainer; +``` +Returns: + +`IContextContainer` + 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 00000000000000..43b4042d04ed6b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.contextsetup.md @@ -0,0 +1,137 @@ + + +[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 configuring handlers with context. + +Signature: + +```typescript +export interface ContextSetup +``` + +## Methods + +| Method | Description | +| --- | --- | +| [createContextContainer()](./kibana-plugin-public.contextsetup.createcontextcontainer.md) | Creates a new [IContextContainer](./kibana-plugin-public.icontextcontainer.md) for a service owner. | + +## Remarks + +A [IContextContainer](./kibana-plugin-public.icontextcontainer.md) 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 configuring 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. + +In order to configure a handler with context, you must call the [IContextContainer.createHandler()](./kibana-plugin-public.icontextcontainer.createhandler.md) function and use the returned handler which will automatically build a context object when called. + +When registering context or creating handlers, the \_calling plugin's opaque id\_ must be provided. This id is passed in via the plugin's initializer and can be accessed from the [PluginInitializerContext.opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) Note this should NOT be the context service owner's id, but the plugin that is actually registering the context or handler. + +```ts +// Correct +class MyPlugin { + private readonly handlers = new Map(); + + setup(core) { + this.contextContainer = core.context.createContextContainer(); + return { + registerContext(pluginOpaqueId, contextName, provider) { + this.contextContainer.registerContext(pluginOpaqueId, contextName, provider); + }, + registerRoute(pluginOpaqueId, path, handler) { + this.handlers.set( + path, + this.contextContainer.createHandler(pluginOpaqueId, handler) + ); + } + } + } +} + +// Incorrect +class MyPlugin { + private readonly handlers = new Map(); + + constructor(private readonly initContext: PluginInitializerContext) {} + + setup(core) { + this.contextContainer = core.context.createContextContainer(); + return { + registerContext(contextName, provider) { + // BUG! + // This would leak this context to all handlers rather that only plugins that depend on the calling plugin. + this.contextContainer.registerContext(this.initContext.opaqueId, contextName, provider); + }, + registerRoute(path, handler) { + this.handlers.set( + path, + // BUG! + // This handler will not receive any contexts provided by other dependencies of the calling plugin. + this.contextContainer.createHandler(this.initContext.opaqueId, handler) + ); + } + } + } +} + +``` + +## Example + +Say we're creating a plugin for rendering visualizations that allows new rendering methods to be registered. If we want to offer context to these rendering methods, we can leverage the ContextService to manage these contexts. + +```ts +export interface VizRenderContext { + core: { + i18n: I18nStart; + uiSettings: UISettingsClientContract; + } + [contextName: string]: unknown; +} + +export type VizRenderer = (context: VizRenderContext, domElement: HTMLElement) => () => void; + +class VizRenderingPlugin { + private readonly vizRenderers = new Map () => void)>(); + + setup(core) { + this.contextContainer = core.createContextContainer< + VizRenderContext, + ReturnType, + [HTMLElement] + >(); + + return { + registerContext: this.contextContainer.registerContext, + registerVizRenderer: (plugin: PluginOpaqueId, renderMethod: string, renderer: VizTypeRenderer) => + this.vizRenderers.set(renderMethod, this.contextContainer.createHandler(plugin, renderer)), + }; + } + + start(core) { + // Register the core context available to all renderers. Use the VizRendererContext's pluginId as the first arg. + this.contextContainer.registerContext('viz_rendering', 'core', () => ({ + i18n: core.i18n, + uiSettings: core.uiSettings + })); + + return { + registerContext: this.contextContainer.registerContext, + + renderVizualization: (renderMethod: string, domElement: HTMLElement) => { + if (!this.vizRenderer.has(renderMethod)) { + throw new Error(`Render method '${renderMethod}' has not been registered`); + } + + // The handler can now be called directly with only an `HTMLElement` and will automatically + // have a new `context` object created and populated by the context container. + const handler = this.vizRenderers.get(renderMethod) + return handler(domElement); + } + }; + } +} + +``` + 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 00000000000000..e56ecb92074c48 --- /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: ContextSetup; +``` diff --git a/docs/development/core/public/kibana-plugin-public.coresetup.md b/docs/development/core/public/kibana-plugin-public.coresetup.md index 5bbd54a2561a31..a4b5b88df36dc4 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) | ContextSetup | [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.icontextcontainer.createhandler.md b/docs/development/core/public/kibana-plugin-public.icontextcontainer.createhandler.md new file mode 100644 index 00000000000000..a02cc0f2e0a399 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.icontextcontainer.createhandler.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IContextContainer](./kibana-plugin-public.icontextcontainer.md) > [createHandler](./kibana-plugin-public.icontextcontainer.createhandler.md) + +## IContextContainer.createHandler() method + +Create a new handler function pre-wired to context for the plugin. + +Signature: + +```typescript +createHandler(pluginOpaqueId: PluginOpaqueId, handler: IContextHandler): (...rest: THandlerParameters) => Promisify; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| pluginOpaqueId | PluginOpaqueId | The plugin opaque ID for the plugin that registers this handler. | +| handler | IContextHandler<TContext, THandlerReturn, THandlerParameters> | Handler function to pass context object to. | + +Returns: + +`(...rest: THandlerParameters) => Promisify` + +A function that takes `THandlerParameters`, calls `handler` with a new context, and returns a Promise of the `handler` return value. + diff --git a/docs/development/core/public/kibana-plugin-public.icontextcontainer.md b/docs/development/core/public/kibana-plugin-public.icontextcontainer.md new file mode 100644 index 00000000000000..0bc7c8f3808d16 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.icontextcontainer.md @@ -0,0 +1,80 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IContextContainer](./kibana-plugin-public.icontextcontainer.md) + +## IContextContainer interface + +An object that handles registration of context providers and configuring handlers with context. + +Signature: + +```typescript +export interface IContextContainer +``` + +## Methods + +| Method | Description | +| --- | --- | +| [createHandler(pluginOpaqueId, handler)](./kibana-plugin-public.icontextcontainer.createhandler.md) | Create a new handler function pre-wired to context for the plugin. | +| [registerContext(pluginOpaqueId, contextName, provider)](./kibana-plugin-public.icontextcontainer.registercontext.md) | Register a new context provider. | + +## Remarks + +A [IContextContainer](./kibana-plugin-public.icontextcontainer.md) 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 configuring 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. + +In order to configure a handler with context, you must call the [IContextContainer.createHandler()](./kibana-plugin-public.icontextcontainer.createhandler.md) function and use the returned handler which will automatically build a context object when called. + +When registering context or creating handlers, the \_calling plugin's opaque id\_ must be provided. This id is passed in via the plugin's initializer and can be accessed from the [PluginInitializerContext.opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) Note this should NOT be the context service owner's id, but the plugin that is actually registering the context or handler. + +```ts +// Correct +class MyPlugin { + private readonly handlers = new Map(); + + setup(core) { + this.contextContainer = core.context.createContextContainer(); + return { + registerContext(pluginOpaqueId, contextName, provider) { + this.contextContainer.registerContext(pluginOpaqueId, contextName, provider); + }, + registerRoute(pluginOpaqueId, path, handler) { + this.handlers.set( + path, + this.contextContainer.createHandler(pluginOpaqueId, handler) + ); + } + } + } +} + +// Incorrect +class MyPlugin { + private readonly handlers = new Map(); + + constructor(private readonly initContext: PluginInitializerContext) {} + + setup(core) { + this.contextContainer = core.context.createContextContainer(); + return { + registerContext(contextName, provider) { + // BUG! + // This would leak this context to all handlers rather that only plugins that depend on the calling plugin. + this.contextContainer.registerContext(this.initContext.opaqueId, contextName, provider); + }, + registerRoute(path, handler) { + this.handlers.set( + path, + // BUG! + // This handler will not receive any contexts provided by other dependencies of the calling plugin. + this.contextContainer.createHandler(this.initContext.opaqueId, handler) + ); + } + } + } +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.icontextcontainer.registercontext.md b/docs/development/core/public/kibana-plugin-public.icontextcontainer.registercontext.md new file mode 100644 index 00000000000000..2cf10a6ec841d6 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.icontextcontainer.registercontext.md @@ -0,0 +1,34 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IContextContainer](./kibana-plugin-public.icontextcontainer.md) > [registerContext](./kibana-plugin-public.icontextcontainer.registercontext.md) + +## IContextContainer.registerContext() method + +Register a new context provider. + +Signature: + +```typescript +registerContext(pluginOpaqueId: PluginOpaqueId, contextName: TContextName, provider: IContextProvider): this; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| pluginOpaqueId | PluginOpaqueId | The plugin opaque ID for the plugin that registers this context. | +| contextName | TContextName | The key of the TContext object this provider supplies the value for. | +| provider | IContextProvider<TContext, TContextName, THandlerParameters> | A [IContextProvider](./kibana-plugin-public.icontextprovider.md) to be called each time a new context is created. | + +Returns: + +`this` + +The [IContextContainer](./kibana-plugin-public.icontextcontainer.md) for method chaining. + +## Remarks + +The value (or resolved Promise value) returned by the `provider` function will be attached to the context object on the key specified by `contextName`. + +Throws an exception if more than one provider is registered for the same `contextName`. + diff --git a/docs/development/core/public/kibana-plugin-public.icontexthandler.md b/docs/development/core/public/kibana-plugin-public.icontexthandler.md new file mode 100644 index 00000000000000..2251b1131c3138 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.icontexthandler.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IContextHandler](./kibana-plugin-public.icontexthandler.md) + +## IContextHandler type + +A function registered by a plugin to perform some action. + +Signature: + +```typescript +export declare type IContextHandler = (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.icontextprovider.md b/docs/development/core/public/kibana-plugin-public.icontextprovider.md new file mode 100644 index 00000000000000..a84917d6e14420 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.icontextprovider.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IContextProvider](./kibana-plugin-public.icontextprovider.md) + +## IContextProvider type + +A function that returns a context value for a specific key of given context type. + +Signature: + +```typescript +export declare type IContextProvider, TContextName extends keyof TContext, TProviderParameters extends any[] = []> = (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.md b/docs/development/core/public/kibana-plugin-public.md index 4cecfb23632a8e..8da53487d5e7a4 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -34,6 +34,7 @@ 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. | +| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | | [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) | | @@ -50,6 +51,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [HttpResponse](./kibana-plugin-public.httpresponse.md) | | | [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) | | | [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | +| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | | [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | | [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | | [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | @@ -69,6 +71,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [HttpHandler](./kibana-plugin-public.httphandler.md) | | | [HttpSetup](./kibana-plugin-public.httpsetup.md) | | | [HttpStart](./kibana-plugin-public.httpstart.md) | | +| [IContextHandler](./kibana-plugin-public.icontexthandler.md) | A function registered by a plugin to perform some action. | +| [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | | [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | | [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | | [ToastInput](./kibana-plugin-public.toastinput.md) | | diff --git a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md index 5dbe464d156189..3ad220349c45c6 100644 --- a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md +++ b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md @@ -11,3 +11,10 @@ The available core services passed to a `PluginInitializer` ```typescript export interface PluginInitializerContext ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) | PluginOpaqueId | A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. | + diff --git a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.opaqueid.md b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.opaqueid.md new file mode 100644 index 00000000000000..10e6b79be49594 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.opaqueid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) > [opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) + +## PluginInitializerContext.opaqueId property + +A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. + +Signature: + +```typescript +readonly opaqueId: PluginOpaqueId; +``` diff --git a/src/core/public/context/context.mock.ts b/src/core/public/context/context.mock.ts new file mode 100644 index 00000000000000..a3849fb77f830f --- /dev/null +++ b/src/core/public/context/context.mock.ts @@ -0,0 +1,34 @@ +/* + * 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 { IContextContainer } from './context'; + +export type ContextContainerMock = jest.Mocked>; + +const createContextMock = () => { + const contextMock: ContextContainerMock = { + registerContext: jest.fn(), + createHandler: 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 00000000000000..d05662af73908e --- /dev/null +++ b/src/core/public/context/context.test.ts @@ -0,0 +1,232 @@ +/* + * 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 } from './context'; +import { PluginOpaqueId } from '../plugins'; + +const pluginA = Symbol('pluginA'); +const pluginB = Symbol('pluginB'); +const pluginC = Symbol('pluginC'); +const pluginD = Symbol('pluginD'); +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; +} + +const coreId = Symbol(); + +describe('ContextContainer', () => { + it('does not allow the same context to be registered twice', () => { + const contextContainer = new ContextContainer(plugins, coreId); + contextContainer.registerContext(coreId, 'ctxFromA', () => 'aString'); + + expect(() => + contextContainer.registerContext(coreId, 'ctxFromA', () => 'aString') + ).toThrowErrorMatchingInlineSnapshot( + `"Context provider for ctxFromA has already been registered."` + ); + }); + + describe('registerContext', () => { + it('throws error if called with an unknown symbol', async () => { + const contextContainer = new ContextContainer(plugins, coreId); + await expect(() => + contextContainer.registerContext(Symbol('unknown'), 'ctxFromA', jest.fn()) + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot register context for unknown plugin: Symbol(unknown)"` + ); + }); + }); + + describe('context building', () => { + it('resolves dependencies', async () => { + const contextContainer = new ContextContainer(plugins, coreId); + expect.assertions(8); + contextContainer.registerContext(coreId, 'core1', context => { + expect(context).toEqual({}); + return 'core'; + }); + + contextContainer.registerContext(pluginA, 'ctxFromA', context => { + expect(context).toEqual({ core1: 'core' }); + return 'aString'; + }); + contextContainer.registerContext(pluginB, 'ctxFromB', context => { + expect(context).toEqual({ core1: 'core', ctxFromA: 'aString' }); + return 299; + }); + contextContainer.registerContext(pluginC, 'ctxFromC', context => { + expect(context).toEqual({ core1: 'core', ctxFromA: 'aString', ctxFromB: 299 }); + return false; + }); + contextContainer.registerContext(pluginD, 'ctxFromD', context => { + expect(context).toEqual({ core1: 'core' }); + return {}; + }); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(pluginC, rawHandler1); + + const rawHandler2 = jest.fn(() => 'handler2'); + const handler2 = contextContainer.createHandler(pluginD, rawHandler2); + + 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 core context to core providers', async () => { + expect.assertions(4); + const contextContainer = new ContextContainer(plugins, coreId); + + contextContainer + .registerContext(coreId, 'core1', context => { + expect(context).toEqual({}); + return 'core'; + }) + .registerContext(coreId, 'core2', context => { + expect(context).toEqual({ core1: 'core' }); + return 101; + }); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(pluginA, 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('does not expose plugin contexts to core handler', async () => { + const contextContainer = new ContextContainer(plugins, coreId); + + contextContainer + .registerContext(coreId, 'core1', context => 'core') + .registerContext(pluginA, 'ctxFromA', context => 'aString'); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(coreId, rawHandler1); + + expect(await handler1()).toEqual('handler1'); + // pluginA context should not be present in a core handler + expect(rawHandler1).toHaveBeenCalledWith({ + core1: 'core', + }); + }); + + it('passes additional arguments to providers', async () => { + expect.assertions(6); + const contextContainer = new ContextContainer( + plugins, + coreId + ); + + contextContainer.registerContext(coreId, 'core1', (context, str, num) => { + expect(str).toEqual('passed string'); + expect(num).toEqual(77); + return `core ${str}`; + }); + + contextContainer.registerContext(pluginD, '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(pluginD, 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 called with an unknown symbol', async () => { + const contextContainer = new ContextContainer(plugins, coreId); + await expect(() => + contextContainer.createHandler(Symbol('unknown'), jest.fn()) + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot create handler for unknown plugin: Symbol(unknown)"` + ); + }); + + it('returns value from original handler', async () => { + const contextContainer = new ContextContainer(plugins, coreId); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(pluginA, rawHandler1); + + expect(await handler1()).toEqual('handler1'); + }); + + it('passes additional arguments to handlers', async () => { + const contextContainer = new ContextContainer( + plugins, + coreId + ); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(pluginA, 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 00000000000000..28f1b8e6ea8783 --- /dev/null +++ b/src/core/public/context/context.ts @@ -0,0 +1,293 @@ +/* + * 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 { pick } from '../../utils'; +import { CoreId } from '../core_system'; +import { PluginOpaqueId } from '../plugins'; + +/** + * 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 which will be resolved before + * attaching to the context object. + * + * @public + */ +export type IContextProvider< + TContext extends Record, + 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 IContextHandler = ( + context: TContext, + ...rest: THandlerParameters +) => TReturn; + +type Promisify = T extends Promise ? Promise : Promise; + +/** + * An object that handles registration of context providers and configuring handlers with context. + * + * @remarks + * A {@link IContextContainer} 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 configuring 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. + * + * In order to configure a handler with context, you must call the {@link IContextContainer.createHandler} function and + * use the returned handler which will automatically build a context object when called. + * + * When registering context or creating handlers, the _calling plugin's opaque id_ must be provided. This id is passed + * in via the plugin's initializer and can be accessed from the {@link PluginInitializerContext.opaqueId} Note this + * should NOT be the context service owner's id, but the plugin that is actually registering the context or handler. + * + * ```ts + * // Correct + * class MyPlugin { + * private readonly handlers = new Map(); + * + * setup(core) { + * this.contextContainer = core.context.createContextContainer(); + * return { + * registerContext(pluginOpaqueId, contextName, provider) { + * this.contextContainer.registerContext(pluginOpaqueId, contextName, provider); + * }, + * registerRoute(pluginOpaqueId, path, handler) { + * this.handlers.set( + * path, + * this.contextContainer.createHandler(pluginOpaqueId, handler) + * ); + * } + * } + * } + * } + * + * // Incorrect + * class MyPlugin { + * private readonly handlers = new Map(); + * + * constructor(private readonly initContext: PluginInitializerContext) {} + * + * setup(core) { + * this.contextContainer = core.context.createContextContainer(); + * return { + * registerContext(contextName, provider) { + * // BUG! + * // This would leak this context to all handlers rather that only plugins that depend on the calling plugin. + * this.contextContainer.registerContext(this.initContext.opaqueId, contextName, provider); + * }, + * registerRoute(path, handler) { + * this.handlers.set( + * path, + * // BUG! + * // This handler will not receive any contexts provided by other dependencies of the calling plugin. + * this.contextContainer.createHandler(this.initContext.opaqueId, handler) + * ); + * } + * } + * } + * } + * ``` + * + * @public + */ +export interface IContextContainer< + TContext extends {}, + THandlerReturn, + THandlerParameters extends any[] = [] +> { + /** + * Register a new context provider. + * + * @remarks + * The value (or resolved Promise value) returned by the `provider` function will be attached to the context object + * on the key specified by `contextName`. + * + * Throws an exception if more than one provider is registered for the same `contextName`. + * + * @param pluginOpaqueId - The plugin opaque ID for the plugin that registers this context. + * @param contextName - The key of the `TContext` object this provider supplies the value for. + * @param provider - A {@link IContextProvider} to be called each time a new context is created. + * @returns The {@link IContextContainer} for method chaining. + */ + registerContext( + pluginOpaqueId: PluginOpaqueId, + contextName: TContextName, + provider: IContextProvider + ): this; + + /** + * Create a new handler function pre-wired to context for the plugin. + * + * @param pluginOpaqueId - The plugin opaque ID for the plugin that registers this handler. + * @param handler - Handler function to pass context object to. + * @returns A function that takes `THandlerParameters`, calls `handler` with a new context, and returns a Promise of + * the `handler` return value. + */ + createHandler( + pluginOpaqueId: PluginOpaqueId, + handler: IContextHandler + ): (...rest: THandlerParameters) => Promisify; +} + +/** @internal */ +export class ContextContainer< + TContext extends Record, + THandlerReturn, + THandlerParameters extends any[] = [] +> implements IContextContainer { + /** + * 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: IContextProvider; + source: symbol; + } + >(); + /** Used to keep track of which plugins registered which contexts for dependency resolution. */ + private readonly contextNamesBySource: Map>; + + /** + * @param pluginDependencies - A map of plugins to an array of their dependencies. + */ + constructor( + private readonly pluginDependencies: ReadonlyMap, + private readonly coreId: CoreId + ) { + this.contextNamesBySource = new Map>([[coreId, []]]); + } + + public registerContext = ( + source: symbol, + contextName: TContextName, + provider: IContextProvider + ): this => { + if (this.contextProviders.has(contextName)) { + throw new Error(`Context provider for ${contextName} has already been registered.`); + } + if (source !== this.coreId && !this.pluginDependencies.has(source)) { + throw new Error(`Cannot register context for unknown plugin: ${source.toString()}`); + } + + this.contextProviders.set(contextName, { provider, source }); + this.contextNamesBySource.set(source, [ + ...(this.contextNamesBySource.get(source) || []), + contextName, + ]); + + return this; + }; + + public createHandler = ( + source: symbol, + handler: IContextHandler + ) => { + if (source !== this.coreId && !this.pluginDependencies.has(source)) { + throw new Error(`Cannot create handler for unknown plugin: ${source.toString()}`); + } + + return (async (...args: THandlerParameters) => { + const context = await this.buildContext(source, ...args); + return handler(context, ...args); + }) as (...args: THandlerParameters) => Promisify; + }; + + private async buildContext( + source: symbol, + ...contextArgs: THandlerParameters + ): Promise { + const contextsToBuild: ReadonlySet = new Set( + this.getContextNamesForSource(source) + ); + + return [...this.contextProviders] + .filter(([contextName]) => contextsToBuild.has(contextName)) + .reduce( + async (contextPromise, [contextName, { provider, source: providerSource }]) => { + const resolvedContext = await contextPromise; + + // For the next provider, only expose the context available based on the dependencies of the plugin that + // registered that provider. + const exposedContext = pick(resolvedContext, [ + ...this.getContextNamesForSource(providerSource), + ]); + + return { + ...resolvedContext, + [contextName]: await provider(exposedContext as Partial, ...contextArgs), + }; + }, + Promise.resolve({}) as Promise + ); + } + + private getContextNamesForSource(source: symbol): ReadonlySet { + if (source === this.coreId) { + return this.getContextNamesForCore(); + } else { + return this.getContextNamesForPluginId(source); + } + } + + private getContextNamesForCore() { + return new Set(this.contextNamesBySource.get(this.coreId)!); + } + + private getContextNamesForPluginId(pluginId: symbol) { + // If the source is a plugin... + const pluginDeps = this.pluginDependencies.get(pluginId); + if (!pluginDeps) { + // This case should never be hit, but let's be safe. + throw new Error(`Cannot create context for unknown plugin: ${pluginId.toString()}`); + } + + return new Set([ + // Core contexts + ...this.contextNamesBySource.get(this.coreId)!, + // Contexts source created + ...(this.contextNamesBySource.get(pluginId) || []), + // Contexts sources's dependencies created + ...flatten(pluginDeps.map(p => this.contextNamesBySource.get(p) || [])), + ]); + } +} 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 00000000000000..289732247b3799 --- /dev/null +++ b/src/core/public/context/context_service.mock.ts @@ -0,0 +1,42 @@ +/* + * 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 { ContextService, ContextSetup } from './context_service'; +import { contextMock } from './context.mock'; + +const createSetupContractMock = () => { + const setupContract: jest.Mocked = { + createContextContainer: jest.fn().mockImplementation(() => contextMock.create()), + }; + return setupContract; +}; + +type ContextServiceContract = PublicMethodsOf; +const createMock = () => { + const mocked: jest.Mocked = { + setup: jest.fn(), + }; + mocked.setup.mockReturnValue(createSetupContractMock()); + return mocked; +}; + +export const contextServiceMock = { + create: createMock, + createSetupContract: createSetupContractMock, +}; 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 00000000000000..765d7d94b19c5c --- /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', () => ({ + ContextContainer: 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 00000000000000..4441a1c5ae6b2b --- /dev/null +++ b/src/core/public/context/context_service.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { MockContextConstructor } from './context_service.test.mocks'; +import { ContextService } from './context_service'; +import { PluginOpaqueId } from '../plugins'; + +const pluginDependencies = new Map(); + +describe('ContextService', () => { + describe('#setup()', () => { + test('createContextContainer returns a new container configured with pluginDependencies', () => { + const coreId = Symbol(); + const service = new ContextService({ coreId }); + const setup = service.setup({ pluginDependencies }); + expect(setup.createContextContainer()).toBeDefined(); + expect(MockContextConstructor).toHaveBeenCalledWith(pluginDependencies, coreId); + }); + }); +}); diff --git a/src/core/public/context/context_service.ts b/src/core/public/context/context_service.ts new file mode 100644 index 00000000000000..7c2d151177f192 --- /dev/null +++ b/src/core/public/context/context_service.ts @@ -0,0 +1,117 @@ +/* + * 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 { IContextContainer, ContextContainer } from './context'; +import { CoreContext } from '../core_system'; +import { PluginOpaqueId } from '../plugins'; + +interface StartDeps { + pluginDependencies: ReadonlyMap; +} + +/** @internal */ +export class ContextService { + constructor(private readonly core: CoreContext) {} + + public setup({ pluginDependencies }: StartDeps): ContextSetup { + return { + createContextContainer: < + TContext extends {}, + THandlerReturn, + THandlerParameters extends any[] = [] + >() => + new ContextContainer( + pluginDependencies, + this.core.coreId + ), + }; + } +} + +/** + * {@inheritdoc IContextContainer} + * + * @example + * Say we're creating a plugin for rendering visualizations that allows new rendering methods to be registered. If we + * want to offer context to these rendering methods, we can leverage the ContextService to manage these contexts. + * ```ts + * export interface VizRenderContext { + * core: { + * i18n: I18nStart; + * uiSettings: UISettingsClientContract; + * } + * [contextName: string]: unknown; + * } + * + * export type VizRenderer = (context: VizRenderContext, domElement: HTMLElement) => () => void; + * + * class VizRenderingPlugin { + * private readonly vizRenderers = new Map () => void)>(); + * + * setup(core) { + * this.contextContainer = core.createContextContainer< + * VizRenderContext, + * ReturnType, + * [HTMLElement] + * >(); + * + * return { + * registerContext: this.contextContainer.registerContext, + * registerVizRenderer: (plugin: PluginOpaqueId, renderMethod: string, renderer: VizTypeRenderer) => + * this.vizRenderers.set(renderMethod, this.contextContainer.createHandler(plugin, renderer)), + * }; + * } + * + * start(core) { + * // Register the core context available to all renderers. Use the VizRendererContext's pluginId as the first arg. + * this.contextContainer.registerContext('viz_rendering', 'core', () => ({ + * i18n: core.i18n, + * uiSettings: core.uiSettings + * })); + * + * return { + * registerContext: this.contextContainer.registerContext, + * + * renderVizualization: (renderMethod: string, domElement: HTMLElement) => { + * if (!this.vizRenderer.has(renderMethod)) { + * throw new Error(`Render method '${renderMethod}' has not been registered`); + * } + * + * // The handler can now be called directly with only an `HTMLElement` and will automatically + * // have a new `context` object created and populated by the context container. + * const handler = this.vizRenderers.get(renderMethod) + * return handler(domElement); + * } + * }; + * } + * } + * ``` + * + * @public + */ +export interface ContextSetup { + /** + * Creates a new {@link IContextContainer} for a service owner. + */ + createContextContainer< + TContext extends {}, + THandlerReturn, + THandlerParmaters extends any[] = [] + >(): IContextContainer; +} diff --git a/src/core/public/context/index.ts b/src/core/public/context/index.ts new file mode 100644 index 00000000000000..0e63e1a6426f02 --- /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 } from './context_service'; +export { IContextContainer, IContextProvider, IContextHandler } from './context'; diff --git a/src/core/public/core_system.test.mocks.ts b/src/core/public/core_system.test.mocks.ts index 4a96214b3e5def..d2494badfacdb5 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 044a40b2759936..7310a8f33eba4e 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'; @@ -51,6 +52,7 @@ const defaultCoreSystemParams = { rootDomElement: document.createElement('div'), browserSupportsCsp: true, injectedMetadata: { + uiPlugins: [], csp: { warnLegacyBrowsers: true, }, @@ -160,6 +162,11 @@ describe('#setup()', () => { expect(MockApplicationService.setup).toHaveBeenCalledTimes(1); }); + it('calls context#setup()', async () => { + await setupCore(); + expect(MockContextService.setup).toHaveBeenCalledTimes(1); + }); + it('calls injectedMetadata#setup()', async () => { await setupCore(); expect(MockInjectedMetadataService.setup).toHaveBeenCalledTimes(1); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index f3f466df8a78e4..a8d071746085c8 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; @@ -44,8 +45,12 @@ interface Params { } /** @internal */ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface CoreContext {} +export type CoreId = symbol; + +/** @internal */ +export interface CoreContext { + coreId: CoreId; +} /** * The CoreSystem is the root of the new platform, and setups all parts @@ -69,6 +74,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; @@ -104,8 +110,9 @@ export class CoreSystem { this.docLinks = new DocLinksService(); this.rendering = new RenderingService(); - const core: CoreContext = {}; - this.plugins = new PluginsService(core); + const core: CoreContext = { coreId: Symbol('core') }; + this.context = new ContextService(core); + this.plugins = new PluginsService(core, injectedMetadata.uiPlugins); this.legacyPlatform = new LegacyPlatformService({ requireLegacyFiles, @@ -127,8 +134,12 @@ export class CoreSystem { const notifications = this.notifications.setup({ uiSettings }); const application = this.application.setup(); + const pluginDependencies = this.plugins.getOpaqueIds(); + const context = this.context.setup({ pluginDependencies }); + const core: InternalCoreSetup = { application, + context, fatalErrors: this.fatalErrorsSetup, http, injectedMetadata, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 91d27b41a0185c..40a6fbf065164c 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 { IContextContainer, IContextProvider, ContextSetup, IContextHandler } from './context'; export { CoreContext, CoreSystem } from './core_system'; export { RecursiveReadonly } from '../utils'; @@ -93,6 +94,8 @@ export { * https://github.com/Microsoft/web-build-tools/issues/1237 */ export interface CoreSetup { + /** {@link ContextSetup} */ + context: ContextSetup; /** {@link FatalErrorsSetup} */ fatalErrors: FatalErrorsSetup; /** {@link HttpSetup} */ @@ -159,6 +162,10 @@ export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, ChromeStart, + IContextContainer, + IContextHandler, + IContextProvider, + ContextSetup, DocLinksStart, ErrorToastOptions, FatalErrorInfo, diff --git a/src/core/public/legacy/legacy_service.test.ts b/src/core/public/legacy/legacy_service.test.ts index a514bf71540550..5f1c4e1cf6bf99 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, diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index b1312eaa228d21..3682d86168dcd2 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/index.ts b/src/core/public/plugins/index.ts index fc16b6b004565e..544d4cf49c632d 100644 --- a/src/core/public/plugins/index.ts +++ b/src/core/public/plugins/index.ts @@ -18,5 +18,5 @@ */ export * from './plugins_service'; -export { Plugin, PluginInitializer } from './plugin'; +export { Plugin, PluginInitializer, PluginOpaqueId } from './plugin'; export { PluginInitializerContext } from './plugin_context'; diff --git a/src/core/public/plugins/plugin.test.ts b/src/core/public/plugins/plugin.test.ts index bbe2baf006a854..6cbe0c7e0ed828 100644 --- a/src/core/public/plugins/plugin.test.ts +++ b/src/core/public/plugins/plugin.test.ts @@ -35,7 +35,8 @@ function createManifest( } let plugin: PluginWrapper>; -const initializerContext = {}; +const opaqueId = Symbol(); +const initializerContext = { opaqueId }; const addBasePath = (path: string) => path; beforeEach(() => { @@ -43,7 +44,7 @@ beforeEach(() => { mockPlugin.setup.mockClear(); mockPlugin.start.mockClear(); mockPlugin.stop.mockClear(); - plugin = new PluginWrapper(createManifest('plugin-a'), initializerContext); + plugin = new PluginWrapper(createManifest('plugin-a'), opaqueId, initializerContext); }); describe('PluginWrapper', () => { diff --git a/src/core/public/plugins/plugin.ts b/src/core/public/plugins/plugin.ts index a24c19e3219f3d..fe870bd23c7a08 100644 --- a/src/core/public/plugins/plugin.ts +++ b/src/core/public/plugins/plugin.ts @@ -22,6 +22,9 @@ import { PluginInitializerContext } from './plugin_context'; import { loadPluginBundle } from './plugin_loader'; import { CoreStart, CoreSetup } from '..'; +/** @public */ +export type PluginOpaqueId = symbol; + /** * The interface that should be returned by a `PluginInitializer`. * @@ -72,6 +75,7 @@ export class PluginWrapper< constructor( readonly discoveredPlugin: DiscoveredPlugin, + public readonly opaqueId: PluginOpaqueId, private readonly initializerContext: PluginInitializerContext ) { this.name = discoveredPlugin.id; diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index bc77b139a86dc5..3711ce08c9992e 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -21,7 +21,7 @@ import { omit } from 'lodash'; import { DiscoveredPlugin } from '../../server'; import { CoreContext } from '../core_system'; -import { PluginWrapper } from './plugin'; +import { PluginWrapper, PluginOpaqueId } from './plugin'; import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; import { CoreSetup, CoreStart } from '../'; @@ -30,8 +30,12 @@ import { CoreSetup, CoreStart } from '../'; * * @public */ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PluginInitializerContext {} +export interface PluginInitializerContext { + /** + * A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. + */ + readonly opaqueId: PluginOpaqueId; +} /** * Provides a plugin-specific context passed to the plugin's construtor. This is currently @@ -43,9 +47,12 @@ export interface PluginInitializerContext {} */ export function createPluginInitializerContext( coreContext: CoreContext, + opaqueId: PluginOpaqueId, pluginManifest: DiscoveredPlugin ): PluginInitializerContext { - return {}; + return { + opaqueId, + }; } /** @@ -69,8 +76,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 4df57b05fda30b..900f20422b8260 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 = { + getOpaqueIds: 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 55e91bde27cb09..bc3fe95cf4c9c1 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -25,7 +25,7 @@ import { mockPluginInitializerProvider, } from './plugins_service.test.mocks'; -import { PluginName } from 'src/core/server'; +import { PluginName, DiscoveredPlugin } from 'src/core/server'; import { CoreContext } from '../core_system'; import { PluginsService, @@ -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; @@ -50,35 +51,37 @@ mockPluginInitializerProvider.mockImplementation( pluginName => mockPluginInitializers.get(pluginName)! ); +let plugins: Array<{ id: string; plugin: DiscoveredPlugin }>; + type DeeplyMocked = { [P in keyof T]: jest.Mocked }; -const mockCoreContext: CoreContext = {}; +const mockCoreContext: CoreContext = { coreId: Symbol() }; let mockSetupDeps: DeeplyMocked; let mockSetupContext: DeeplyMocked; let mockStartDeps: DeeplyMocked; let mockStartContext: DeeplyMocked; beforeEach(() => { + plugins = [ + { id: 'pluginA', plugin: createManifest('pluginA') }, + { id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) }, + { + id: 'pluginC', + plugin: createManifest('pluginC', { required: ['pluginA'], optional: ['nonexist'] }), + }, + ]; mockSetupDeps = { application: applicationServiceMock.createSetupContract(), - injectedMetadata: (function() { - const metadata = injectedMetadataServiceMock.createSetupContract(); - metadata.getPlugins.mockReturnValue([ - { id: 'pluginA', plugin: createManifest('pluginA') }, - { id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) }, - { - id: 'pluginC', - plugin: createManifest('pluginC', { required: ['pluginA'], optional: ['nonexist'] }), - }, - ]); - return metadata; - })(), + context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), notifications: notificationServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), }; - mockSetupContext = omit(mockSetupDeps, 'application', 'injectedMetadata'); + mockSetupContext = { + ...omit(mockSetupDeps, 'application', 'injectedMetadata'), + }; mockStartDeps = { application: applicationServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), @@ -148,10 +151,25 @@ function createManifest( }; } +test('`PluginsService.getOpaqueIds` returns dependency tree of symbols', () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); + expect(pluginsService.getOpaqueIds()).toMatchInlineSnapshot(` + Map { + Symbol(pluginA) => Array [], + Symbol(pluginB) => Array [ + Symbol(pluginA), + ], + Symbol(pluginC) => Array [ + Symbol(pluginA), + ], + } + `); +}); + test('`PluginsService.setup` fails if any bundle cannot be loaded', async () => { mockLoadPluginBundle.mockRejectedValueOnce(new Error('Could not load bundle')); - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot( `"Could not load bundle"` ); @@ -159,14 +177,14 @@ test('`PluginsService.setup` fails if any bundle cannot be loaded', async () => test('`PluginsService.setup` fails if any plugin instance does not have a setup function', async () => { mockPluginInitializers.set('pluginA', (() => ({})) as any); - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot( `"Instance of plugin \\"pluginA\\" does not define \\"setup\\" function."` ); }); test('`PluginsService.setup` calls loadPluginBundles with http and plugins', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); expect(mockLoadPluginBundle).toHaveBeenCalledTimes(3); @@ -175,17 +193,17 @@ test('`PluginsService.setup` calls loadPluginBundles with http and plugins', asy expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockSetupDeps.http.basePath.prepend, 'pluginC'); }); -test('`PluginsService.setup` initalizes plugins with CoreContext', async () => { - const pluginsService = new PluginsService(mockCoreContext); +test('`PluginsService.setup` initalizes plugins with PluginIntitializerContext', async () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); - expect(mockPluginInitializers.get('pluginA')).toHaveBeenCalledWith(mockCoreContext); - expect(mockPluginInitializers.get('pluginB')).toHaveBeenCalledWith(mockCoreContext); - expect(mockPluginInitializers.get('pluginC')).toHaveBeenCalledWith(mockCoreContext); + expect(mockPluginInitializers.get('pluginA')).toHaveBeenCalledWith(expect.any(Object)); + expect(mockPluginInitializers.get('pluginB')).toHaveBeenCalledWith(expect.any(Object)); + expect(mockPluginInitializers.get('pluginC')).toHaveBeenCalledWith(expect.any(Object)); }); test('`PluginsService.setup` exposes dependent setup contracts to plugins', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; @@ -203,15 +221,13 @@ test('`PluginsService.setup` exposes dependent setup contracts to plugins', asyn }); test('`PluginsService.setup` does not set missing dependent setup contracts', async () => { - mockSetupDeps.injectedMetadata.getPlugins.mockReturnValue([ - { id: 'pluginD', plugin: createManifest('pluginD', { required: ['missing'] }) }, - ]); + plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }]; mockPluginInitializers.set('pluginD', jest.fn(() => ({ setup: jest.fn(), start: jest.fn(), })) as any); - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); // If a dependency is missing it should not be in the deps at all, not even as undefined. @@ -222,7 +238,7 @@ test('`PluginsService.setup` does not set missing dependent setup contracts', as }); test('`PluginsService.setup` returns plugin setup contracts', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); const { contracts } = await pluginsService.setup(mockSetupDeps); // Verify that plugin contracts were available @@ -231,7 +247,7 @@ test('`PluginsService.setup` returns plugin setup contracts', async () => { }); test('`PluginsService.start` exposes dependent start contracts to plugins', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); await pluginsService.start(mockStartDeps); @@ -250,15 +266,13 @@ test('`PluginsService.start` exposes dependent start contracts to plugins', asyn }); test('`PluginsService.start` does not set missing dependent start contracts', async () => { - mockSetupDeps.injectedMetadata.getPlugins.mockReturnValue([ - { id: 'pluginD', plugin: createManifest('pluginD', { required: ['missing'] }) }, - ]); + plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }]; mockPluginInitializers.set('pluginD', jest.fn(() => ({ setup: jest.fn(), start: jest.fn(), })) as any); - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); await pluginsService.start(mockStartDeps); @@ -270,7 +284,7 @@ test('`PluginsService.start` does not set missing dependent start contracts', as }); test('`PluginsService.start` returns plugin start contracts', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); const { contracts } = await pluginsService.start(mockStartDeps); @@ -280,7 +294,7 @@ test('`PluginsService.start` returns plugin start contracts', async () => { }); test('`PluginService.stop` calls the stop function on each plugin', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts index 03725a9d7f8839..902c883c74bbc4 100644 --- a/src/core/public/plugins/plugins_service.ts +++ b/src/core/public/plugins/plugins_service.ts @@ -17,10 +17,10 @@ * 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'; +import { PluginWrapper, PluginOpaqueId } from './plugin'; import { createPluginInitializerContext, createPluginSetupContext, @@ -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,37 +50,56 @@ 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) {} + constructor( + private readonly coreContext: CoreContext, + plugins: Array<{ id: PluginName; plugin: DiscoveredPlugin }> + ) { + // Generate opaque ids + const opaqueIds = new Map(plugins.map(p => [p.id, Symbol(p.id)])); + + // Setup dependency map and plugin wrappers + plugins.forEach(({ id, plugin }) => { + // Setup map of dependencies + this.pluginDependencies.set(id, [ + ...plugin.requiredPlugins, + ...plugin.optionalPlugins.filter(optPlugin => opaqueIds.has(optPlugin)), + ]); - public async setup(deps: PluginsServiceSetupDeps) { - // Construct plugin wrappers, depending on the topological order set by the server. - deps.injectedMetadata - .getPlugins() - .forEach(({ id, plugin }) => - this.plugins.set( - id, - new PluginWrapper(plugin, createPluginInitializerContext(deps, plugin)) + // Construct plugin wrappers, depending on the topological order set by the server. + this.plugins.set( + id, + new PluginWrapper( + plugin, + opaqueIds.get(id)!, + createPluginInitializerContext(this.coreContext, opaqueIds.get(id)!, plugin) ) ); + }); + } + + public getOpaqueIds(): ReadonlyMap { + // Return dependency map of opaque ids + return new Map( + [...this.pluginDependencies].map(([id, deps]) => [ + this.plugins.get(id)!.opaqueId, + deps.map(depId => this.plugins.get(depId)!.opaqueId), + ]) + ); + } + public async setup(deps: PluginsServiceSetupDeps): Promise { // Load plugin bundles await this.loadPluginBundles(deps.http.basePath.prepend); // 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)), - ]); - - 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. @@ -108,16 +127,11 @@ 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)), - ]); - - 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. diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 94063a99f25a3a..6058bc3dcb8097 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -167,12 +167,23 @@ export interface ChromeStart { setIsVisible(isVisible: boolean): void; } +// @public +export interface ContextSetup { + createContextContainer(): IContextContainer; +} + // @internal (undocumented) export interface CoreContext { + // Warning: (ae-forgotten-export) The symbol "CoreId" needs to be exported by the entry point index.d.ts + // + // (undocumented) + coreId: CoreId; } // @public export interface CoreSetup { + // (undocumented) + context: ContextSetup; // (undocumented) fatalErrors: FatalErrorsSetup; // (undocumented) @@ -477,6 +488,20 @@ export interface I18nStart { }) => JSX.Element; } +// @public +export interface IContextContainer { + // Warning: (ae-forgotten-export) The symbol "Promisify" needs to be exported by the entry point index.d.ts + createHandler(pluginOpaqueId: PluginOpaqueId, handler: IContextHandler): (...rest: THandlerParameters) => Promisify; + // Warning: (ae-forgotten-export) The symbol "PluginOpaqueId" needs to be exported by the entry point index.d.ts + registerContext(pluginOpaqueId: PluginOpaqueId, contextName: TContextName, provider: IContextProvider): this; +} + +// @public +export type IContextHandler = (context: TContext, ...rest: THandlerParameters) => TReturn; + +// @public +export type IContextProvider, TContextName extends keyof TContext, TProviderParameters extends any[] = []> = (context: Partial, ...rest: TProviderParameters) => Promise | TContext[TContextName]; + // @internal (undocumented) export interface InternalCoreSetup extends CoreSetup { // (undocumented) @@ -567,6 +592,7 @@ export type PluginInitializer(map: Map) { +export function mapToObject(map: ReadonlyMap) { const result: Record = Object.create(null); for (const [key, value] of map) { result[key] = value;