[7.x] Add ContextService (#41251) (#42278)

This commit is contained in:
Josh Dover 2019-07-30 17:55:48 -05:00 committed by GitHub
parent 9be59c0b10
commit 1898510de8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1353 additions and 79 deletions

View file

@ -0,0 +1,17 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ContextSetup](./kibana-plugin-public.contextsetup.md) &gt; [createContextContainer](./kibana-plugin-public.contextsetup.createcontextcontainer.md)
## ContextSetup.createContextContainer() method
Creates a new [IContextContainer](./kibana-plugin-public.icontextcontainer.md) for a service owner.
<b>Signature:</b>
```typescript
createContextContainer<TContext extends {}, THandlerReturn, THandlerParmaters extends any[] = []>(): IContextContainer<TContext, THandlerReturn, THandlerParmaters>;
```
<b>Returns:</b>
`IContextContainer<TContext, THandlerReturn, THandlerParmaters>`

View file

@ -0,0 +1,137 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ContextSetup](./kibana-plugin-public.contextsetup.md)
## ContextSetup interface
An object that handles registration of context providers and configuring handlers with context.
<b>Signature:</b>
```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<string, ((domElement: HTMLElement) => () => void)>();
setup(core) {
this.contextContainer = core.createContextContainer<
VizRenderContext,
ReturnType<VizRenderer>,
[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);
}
};
}
}
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [CoreSetup](./kibana-plugin-public.coresetup.md) &gt; [context](./kibana-plugin-public.coresetup.context.md)
## CoreSetup.context property
[ContextSetup](./kibana-plugin-public.contextsetup.md)
<b>Signature:</b>
```typescript
context: ContextSetup;
```

View file

@ -16,6 +16,7 @@ export interface CoreSetup
| Property | Type | Description |
| --- | --- | --- |
| [context](./kibana-plugin-public.coresetup.context.md) | <code>ContextSetup</code> | [ContextSetup](./kibana-plugin-public.contextsetup.md) |
| [fatalErrors](./kibana-plugin-public.coresetup.fatalerrors.md) | <code>FatalErrorsSetup</code> | [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) |
| [http](./kibana-plugin-public.coresetup.http.md) | <code>HttpSetup</code> | [HttpSetup](./kibana-plugin-public.httpsetup.md) |
| [notifications](./kibana-plugin-public.coresetup.notifications.md) | <code>NotificationsSetup</code> | [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) |

View file

@ -0,0 +1,27 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IContextContainer](./kibana-plugin-public.icontextcontainer.md) &gt; [createHandler](./kibana-plugin-public.icontextcontainer.createhandler.md)
## IContextContainer.createHandler() method
Create a new handler function pre-wired to context for the plugin.
<b>Signature:</b>
```typescript
createHandler(pluginOpaqueId: PluginOpaqueId, handler: IContextHandler<TContext, THandlerReturn, THandlerParameters>): (...rest: THandlerParameters) => Promisify<THandlerReturn>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| pluginOpaqueId | <code>PluginOpaqueId</code> | The plugin opaque ID for the plugin that registers this handler. |
| handler | <code>IContextHandler&lt;TContext, THandlerReturn, THandlerParameters&gt;</code> | Handler function to pass context object to. |
<b>Returns:</b>
`(...rest: THandlerParameters) => Promisify<THandlerReturn>`
A function that takes `THandlerParameters`<!-- -->, calls `handler` with a new context, and returns a Promise of the `handler` return value.

View file

@ -0,0 +1,80 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IContextContainer](./kibana-plugin-public.icontextcontainer.md)
## IContextContainer interface
An object that handles registration of context providers and configuring handlers with context.
<b>Signature:</b>
```typescript
export interface IContextContainer<TContext extends {}, THandlerReturn, THandlerParameters extends any[] = []>
```
## 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)
);
}
}
}
}
```

View file

@ -0,0 +1,34 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IContextContainer](./kibana-plugin-public.icontextcontainer.md) &gt; [registerContext](./kibana-plugin-public.icontextcontainer.registercontext.md)
## IContextContainer.registerContext() method
Register a new context provider.
<b>Signature:</b>
```typescript
registerContext<TContextName extends keyof TContext>(pluginOpaqueId: PluginOpaqueId, contextName: TContextName, provider: IContextProvider<TContext, TContextName, THandlerParameters>): this;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| pluginOpaqueId | <code>PluginOpaqueId</code> | The plugin opaque ID for the plugin that registers this context. |
| contextName | <code>TContextName</code> | The key of the <code>TContext</code> object this provider supplies the value for. |
| provider | <code>IContextProvider&lt;TContext, TContextName, THandlerParameters&gt;</code> | A [IContextProvider](./kibana-plugin-public.icontextprovider.md) to be called each time a new context is created. |
<b>Returns:</b>
`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`<!-- -->.

View file

@ -0,0 +1,18 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IContextHandler](./kibana-plugin-public.icontexthandler.md)
## IContextHandler type
A function registered by a plugin to perform some action.
<b>Signature:</b>
```typescript
export declare type IContextHandler<TContext extends {}, TReturn, THandlerParameters extends any[] = []> = (context: TContext, ...rest: THandlerParameters) => TReturn;
```
## Remarks
A new `TContext` will be built for each handler before invoking.

View file

@ -0,0 +1,18 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [IContextProvider](./kibana-plugin-public.icontextprovider.md)
## IContextProvider type
A function that returns a context value for a specific key of given context type.
<b>Signature:</b>
```typescript
export declare type IContextProvider<TContext extends Record<string, any>, TContextName extends keyof TContext, TProviderParameters extends any[] = []> = (context: Partial<TContext>, ...rest: TProviderParameters) => Promise<TContext[TContextName]> | TContext[TContextName];
```
## Remarks
This function will be called each time a new context is built for a handler invocation.

View file

@ -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 <code>Plugin</code> setup lifecycle |
| [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the <code>Plugin</code> 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 <code>plugin</code> export at the root of a plugin's <code>public</code> directory should conform to this interface. |
| [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | |
| [ToastInput](./kibana-plugin-public.toastinput.md) | |

View file

@ -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) | <code>PluginOpaqueId</code> | A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) &gt; [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.
<b>Signature:</b>
```typescript
readonly opaqueId: PluginOpaqueId;
```

View file

@ -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<IContextContainer<any, any, any>>;
const createContextMock = () => {
const contextMock: ContextContainerMock = {
registerContext: jest.fn(),
createHandler: jest.fn(),
};
return contextMock;
};
export const contextMock = {
create: createContextMock,
};

View file

@ -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<PluginOpaqueId, PluginOpaqueId[]> = 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<MyContext, string>(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<MyContext, string>(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<MyContext, string>(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<string, []>(() => 'handler1');
const handler1 = contextContainer.createHandler(pluginC, rawHandler1);
const rawHandler2 = jest.fn<string, []>(() => '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<MyContext, string>(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<string, []>(() => '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<MyContext, string>(plugins, coreId);
contextContainer
.registerContext(coreId, 'core1', context => 'core')
.registerContext(pluginA, 'ctxFromA', context => 'aString');
const rawHandler1 = jest.fn<string, []>(() => '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<MyContext, string, [string, number]>(
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<string, [MyContext, string, number]>(() => '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<MyContext, string>(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<MyContext, string>(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<MyContext, string, [string, number]>(
plugins,
coreId
);
const rawHandler1 = jest.fn<string, [MyContext, string, number]>(() => 'handler1');
const handler1 = contextContainer.createHandler(pluginA, rawHandler1);
await handler1('passed string', 77);
expect(rawHandler1).toHaveBeenCalledWith({}, 'passed string', 77);
});
});
});

View file

@ -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<string, any>,
TContextName extends keyof TContext,
TProviderParameters extends any[] = []
> = (
context: Partial<TContext>,
...rest: TProviderParameters
) => Promise<TContext[TContextName]> | 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<TContext extends {}, TReturn, THandlerParameters extends any[] = []> = (
context: TContext,
...rest: THandlerParameters
) => TReturn;
type Promisify<T> = T extends Promise<infer U> ? Promise<U> : Promise<T>;
/**
* 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<TContextName extends keyof TContext>(
pluginOpaqueId: PluginOpaqueId,
contextName: TContextName,
provider: IContextProvider<TContext, TContextName, THandlerParameters>
): 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<TContext, THandlerReturn, THandlerParameters>
): (...rest: THandlerParameters) => Promisify<THandlerReturn>;
}
/** @internal */
export class ContextContainer<
TContext extends Record<string, any>,
THandlerReturn,
THandlerParameters extends any[] = []
> implements IContextContainer<TContext, THandlerReturn, THandlerParameters> {
/**
* 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<TContext, keyof TContext, THandlerParameters>;
source: symbol;
}
>();
/** Used to keep track of which plugins registered which contexts for dependency resolution. */
private readonly contextNamesBySource: Map<symbol, Array<keyof TContext>>;
/**
* @param pluginDependencies - A map of plugins to an array of their dependencies.
*/
constructor(
private readonly pluginDependencies: ReadonlyMap<PluginOpaqueId, PluginOpaqueId[]>,
private readonly coreId: CoreId
) {
this.contextNamesBySource = new Map<symbol, Array<keyof TContext>>([[coreId, []]]);
}
public registerContext = <TContextName extends keyof TContext>(
source: symbol,
contextName: TContextName,
provider: IContextProvider<TContext, TContextName, THandlerParameters>
): 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<TContext, THandlerReturn, THandlerParameters>
) => {
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<THandlerReturn>;
};
private async buildContext(
source: symbol,
...contextArgs: THandlerParameters
): Promise<TContext> {
const contextsToBuild: ReadonlySet<keyof TContext> = 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<TContext>, ...contextArgs),
};
},
Promise.resolve({}) as Promise<TContext>
);
}
private getContextNamesForSource(source: symbol): ReadonlySet<keyof TContext> {
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) || [])),
]);
}
}

View file

@ -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<ContextSetup> = {
createContextContainer: jest.fn().mockImplementation(() => contextMock.create()),
};
return setupContract;
};
type ContextServiceContract = PublicMethodsOf<ContextService>;
const createMock = () => {
const mocked: jest.Mocked<ContextServiceContract> = {
setup: jest.fn(),
};
mocked.setup.mockReturnValue(createSetupContractMock());
return mocked;
};
export const contextServiceMock = {
create: createMock,
createSetupContract: createSetupContractMock,
};

View file

@ -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,
}));

View file

@ -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<PluginOpaqueId, PluginOpaqueId[]>();
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);
});
});
});

View file

@ -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<PluginOpaqueId, PluginOpaqueId[]>;
}
/** @internal */
export class ContextService {
constructor(private readonly core: CoreContext) {}
public setup({ pluginDependencies }: StartDeps): ContextSetup {
return {
createContextContainer: <
TContext extends {},
THandlerReturn,
THandlerParameters extends any[] = []
>() =>
new ContextContainer<TContext, THandlerReturn, THandlerParameters>(
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<string, ((domElement: HTMLElement) => () => void)>();
*
* setup(core) {
* this.contextContainer = core.createContextContainer<
* VizRenderContext,
* ReturnType<VizRenderer>,
* [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<TContext, THandlerReturn, THandlerParmaters>;
}

View file

@ -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';

View file

@ -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,
}));

View file

@ -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);

View file

@ -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,

View file

@ -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';
/** @interal */
export { CoreContext, CoreSystem } from './core_system';
@ -94,6 +95,8 @@ export {
* https://github.com/Microsoft/web-build-tools/issues/1237
*/
export interface CoreSetup {
/** {@link ContextSetup} */
context: ContextSetup;
/** {@link FatalErrorsSetup} */
fatalErrors: FatalErrorsSetup;
/** {@link HttpSetup} */
@ -160,6 +163,10 @@ export {
ChromeRecentlyAccessed,
ChromeRecentlyAccessedHistoryItem,
ChromeStart,
IContextContainer,
IContextHandler,
IContextProvider,
ContextSetup,
DocLinksStart,
ErrorToastOptions,
FatalErrorInfo,

View file

@ -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,

View file

@ -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<CoreSetup> = {
context: contextServiceMock.createSetupContract(),
fatalErrors: fatalErrorsServiceMock.createSetupContract(),
http: httpServiceMock.createSetupContract(),
notifications: notificationServiceMock.createSetupContract(),

View file

@ -18,5 +18,5 @@
*/
export * from './plugins_service';
export { Plugin, PluginInitializer } from './plugin';
export { Plugin, PluginInitializer, PluginOpaqueId } from './plugin';
export { PluginInitializerContext } from './plugin_context';

View file

@ -35,7 +35,8 @@ function createManifest(
}
let plugin: PluginWrapper<unknown, Record<string, unknown>>;
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', () => {

View file

@ -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;

View file

@ -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<TSetup, TStart, TPluginsSetup, TPluginsStart>
): CoreSetup {
return {
http: deps.http,
context: omit(deps.context, 'setCurrentPlugin'),
fatalErrors: deps.fatalErrors,
http: deps.http,
notifications: deps.notifications,
uiSettings: deps.uiSettings,
};

View file

@ -38,6 +38,7 @@ const createStartContractMock = () => {
type PluginsServiceContract = PublicMethodsOf<PluginsService>;
const createMock = () => {
const mocked: jest.Mocked<PluginsServiceContract> = {
getOpaqueIds: jest.fn(),
setup: jest.fn(),
start: jest.fn(),
stop: jest.fn(),

View file

@ -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<PluginName, MockedPluginInitializer>;
@ -50,35 +51,37 @@ mockPluginInitializerProvider.mockImplementation(
pluginName => mockPluginInitializers.get(pluginName)!
);
let plugins: Array<{ id: string; plugin: DiscoveredPlugin }>;
type DeeplyMocked<T> = { [P in keyof T]: jest.Mocked<T[P]> };
const mockCoreContext: CoreContext = {};
const mockCoreContext: CoreContext = { coreId: Symbol() };
let mockSetupDeps: DeeplyMocked<PluginsServiceSetupDeps>;
let mockSetupContext: DeeplyMocked<CoreSetup>;
let mockStartDeps: DeeplyMocked<PluginsServiceStartDeps>;
let mockStartContext: DeeplyMocked<CoreStart>;
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;

View file

@ -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<string, unknown>;
contracts: ReadonlyMap<string, unknown>;
}
/** @internal */
export interface PluginsServiceStart {
contracts: Map<string, unknown>;
contracts: ReadonlyMap<string, unknown>;
}
/**
@ -50,37 +50,56 @@ export interface PluginsServiceStart {
*/
export class PluginsService implements CoreService<PluginsServiceSetup, PluginsServiceStart> {
/** Plugin wrappers in topological order. */
private readonly plugins: Map<
PluginName,
PluginWrapper<unknown, Record<string, unknown>>
> = new Map();
private readonly plugins = new Map<PluginName, PluginWrapper<unknown, Record<string, unknown>>>();
private readonly pluginDependencies = new Map<PluginName, PluginName[]>();
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<PluginName, PluginOpaqueId>(plugins.map(p => [p.id, Symbol(p.id)]));
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))
// 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)),
]);
// 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<PluginOpaqueId, PluginOpaqueId[]> {
// 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<PluginsServiceSetup> {
// Load plugin bundles
await this.loadPluginBundles(deps.http.basePath.prepend);
// Setup each plugin with required and optional plugin contracts
const contracts = new Map<string, unknown>();
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<PluginsServiceSetup, PluginsS
return { contracts };
}
public async start(deps: PluginsServiceStartDeps) {
public async start(deps: PluginsServiceStartDeps): Promise<PluginsServiceStart> {
// Setup each plugin with required and optional plugin contracts
const contracts = new Map<string, unknown>();
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.

View file

@ -167,12 +167,23 @@ export interface ChromeStart {
setIsVisible(isVisible: boolean): void;
}
// @public
export interface ContextSetup {
createContextContainer<TContext extends {}, THandlerReturn, THandlerParmaters extends any[] = []>(): IContextContainer<TContext, THandlerReturn, THandlerParmaters>;
}
// @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<TContext extends {}, THandlerReturn, THandlerParameters extends any[] = []> {
// Warning: (ae-forgotten-export) The symbol "Promisify" needs to be exported by the entry point index.d.ts
createHandler(pluginOpaqueId: PluginOpaqueId, handler: IContextHandler<TContext, THandlerReturn, THandlerParameters>): (...rest: THandlerParameters) => Promisify<THandlerReturn>;
// Warning: (ae-forgotten-export) The symbol "PluginOpaqueId" needs to be exported by the entry point index.d.ts
registerContext<TContextName extends keyof TContext>(pluginOpaqueId: PluginOpaqueId, contextName: TContextName, provider: IContextProvider<TContext, TContextName, THandlerParameters>): this;
}
// @public
export type IContextHandler<TContext extends {}, TReturn, THandlerParameters extends any[] = []> = (context: TContext, ...rest: THandlerParameters) => TReturn;
// @public
export type IContextProvider<TContext extends Record<string, any>, TContextName extends keyof TContext, TProviderParameters extends any[] = []> = (context: Partial<TContext>, ...rest: TProviderParameters) => Promise<TContext[TContextName]> | TContext[TContextName];
// @internal (undocumented)
export interface InternalCoreSetup extends CoreSetup {
// (undocumented)
@ -567,6 +592,7 @@ export type PluginInitializer<TSetup, TStart, TPluginsSetup extends object = obj
// @public
export interface PluginInitializerContext {
readonly opaqueId: PluginOpaqueId;
}
// Warning: (ae-forgotten-export) The symbol "RecursiveReadonlyArray" needs to be exported by the entry point index.d.ts

View file

@ -17,7 +17,7 @@
* under the License.
*/
export function mapToObject<V = unknown>(map: Map<string, V>) {
export function mapToObject<V = unknown>(map: ReadonlyMap<string, V>) {
const result: Record<string, V> = Object.create(null);
for (const [key, value] of map) {
result[key] = value;