diff --git a/NOTICE.txt b/NOTICE.txt index 230e5117460224..955c3127fa9559 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,5 +1,5 @@ Kibana source code with Kibana X-Pack source code -Copyright 2012-2019 Elasticsearch B.V. +Copyright 2012-2020 Elasticsearch B.V. --- Pretty handling of logarithmic axes. diff --git a/docs/development/core/public/kibana-plugin-public.app.approute.md b/docs/development/core/public/kibana-plugin-public.app.approute.md new file mode 100644 index 00000000000000..7f35f4346b6b3a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.app.approute.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [App](./kibana-plugin-public.app.md) > [appRoute](./kibana-plugin-public.app.approute.md) + +## App.appRoute property + +Override the application's routing path from `/app/${id}`. Must be unique across registered applications. Should not include the base path from HTTP. + +Signature: + +```typescript +appRoute?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.app.md b/docs/development/core/public/kibana-plugin-public.app.md index edab4f88497f64..acf07cbf62e91e 100644 --- a/docs/development/core/public/kibana-plugin-public.app.md +++ b/docs/development/core/public/kibana-plugin-public.app.md @@ -16,6 +16,7 @@ export interface App extends AppBase | Property | Type | Description | | --- | --- | --- | +| [appRoute](./kibana-plugin-public.app.approute.md) | string | Override the application's routing path from /app/${id}. Must be unique across registered applications. Should not include the base path from HTTP. | | [chromeless](./kibana-plugin-public.app.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | | [mount](./kibana-plugin-public.app.mount.md) | AppMount | AppMountDeprecated | A mount function called when the user navigates to this app's route. May have signature of [AppMount](./kibana-plugin-public.appmount.md) or [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md). | diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md index a1544373ee6980..7cd709d615729f 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.appbasepath.md @@ -4,7 +4,7 @@ ## AppMountParameters.appBasePath property -The base path for configuring the application's router. +The route path for configuring navigation to the application. This string should not include the base path from HTTP. Signature: @@ -22,6 +22,7 @@ export class MyPlugin implements Plugin { setup({ application }) { application.register({ id: 'my-app', + appRoute: '/my-app', async mount(params) { const { renderApp } = await import('./application'); return renderApp(params); diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.md index 8733f9cd4915d3..aa5ca93ed8ff03 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.md @@ -15,6 +15,6 @@ export interface AppMountParameters | Property | Type | Description | | --- | --- | --- | -| [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) | string | The base path for configuring the application's router. | +| [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) | string | The route path for configuring navigation to the application. This string should not include the base path from HTTP. | | [element](./kibana-plugin-public.appmountparameters.element.md) | HTMLElement | The container element to render the application into. | diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md index a4fa3f17d0d94f..1ce18834f53196 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md @@ -9,5 +9,5 @@ Search for objects Signature: ```typescript -find: (options: Pick) => Promise>; +find: (options: Pick) => Promise>; ``` diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md index 3c4e33db4af91b..6033c667c1866c 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md @@ -20,7 +20,7 @@ export declare class SavedObjectsClient | [bulkGet](./kibana-plugin-public.savedobjectsclient.bulkget.md) | | (objects?: {
id: string;
type: string;
}[]) => Promise<SavedObjectsBatchResponse<SavedObjectAttributes>> | Returns an array of objects by id | | [create](./kibana-plugin-public.savedobjectsclient.create.md) | | <T extends SavedObjectAttributes>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>> | Persists an object | | [delete](./kibana-plugin-public.savedobjectsclient.delete.md) | | (type: string, id: string) => Promise<{}> | Deletes an object | -| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "filter" | "type" | "page" | "perPage" | "sortField" | "fields" | "searchFields" | "hasReference" | "defaultSearchOperator">) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | +| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "filter" | "type" | "page" | "fields" | "searchFields" | "defaultSearchOperator" | "hasReference" | "sortField" | "perPage">) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | | [get](./kibana-plugin-public.savedobjectsclient.get.md) | | <T extends SavedObjectAttributes>(type: string, id: string) => Promise<SimpleSavedObject<T>> | Fetches a single object | ## Methods diff --git a/docs/development/core/server/kibana-plugin-server.basepath.get.md b/docs/development/core/server/kibana-plugin-server.basepath.get.md index 3f7895dd727996..6ef7022f10e624 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.get.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.get.md @@ -1,13 +1,13 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [BasePath](./kibana-plugin-server.basepath.md) > [get](./kibana-plugin-server.basepath.get.md) - -## BasePath.get property - -returns `basePath` value, specific for an incoming request. - -Signature: - -```typescript -(request: KibanaRequest | LegacyRequest) => string; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [BasePath](./kibana-plugin-server.basepath.md) > [get](./kibana-plugin-server.basepath.get.md) + +## BasePath.get property + +returns `basePath` value, specific for an incoming request. + +Signature: + +```typescript +get: (request: KibanaRequest | LegacyRequest) => string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.basepath.set.md b/docs/development/core/server/kibana-plugin-server.basepath.set.md index 633765389e649b..56a7f644d34ccc 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.set.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.set.md @@ -1,13 +1,13 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [BasePath](./kibana-plugin-server.basepath.md) > [set](./kibana-plugin-server.basepath.set.md) - -## BasePath.set property - -sets `basePath` value, specific for an incoming request. - -Signature: - -```typescript -(request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [BasePath](./kibana-plugin-server.basepath.md) > [set](./kibana-plugin-server.basepath.set.md) + +## BasePath.set property + +sets `basePath` value, specific for an incoming request. + +Signature: + +```typescript +set: (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; +``` diff --git a/docs/development/core/server/kibana-plugin-server.irenderoptions.includeusersettings.md b/docs/development/core/server/kibana-plugin-server.irenderoptions.includeusersettings.md new file mode 100644 index 00000000000000..cedf3d27d0887d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.irenderoptions.includeusersettings.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IRenderOptions](./kibana-plugin-server.irenderoptions.md) > [includeUserSettings](./kibana-plugin-server.irenderoptions.includeusersettings.md) + +## IRenderOptions.includeUserSettings property + +Set whether to output user settings in the page metadata. `true` by default. + +Signature: + +```typescript +includeUserSettings?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.irenderoptions.md b/docs/development/core/server/kibana-plugin-server.irenderoptions.md new file mode 100644 index 00000000000000..34bed8b5e078c1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.irenderoptions.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IRenderOptions](./kibana-plugin-server.irenderoptions.md) + +## IRenderOptions interface + + +Signature: + +```typescript +export interface IRenderOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [includeUserSettings](./kibana-plugin-server.irenderoptions.includeusersettings.md) | boolean | Set whether to output user settings in the page metadata. true by default. | + diff --git a/docs/development/core/server/kibana-plugin-server.irouter.handlelegacyerrors.md b/docs/development/core/server/kibana-plugin-server.irouter.handlelegacyerrors.md index 238424b1df1d52..ff71f13466cf88 100644 --- a/docs/development/core/server/kibana-plugin-server.irouter.handlelegacyerrors.md +++ b/docs/development/core/server/kibana-plugin-server.irouter.handlelegacyerrors.md @@ -1,13 +1,13 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IRouter](./kibana-plugin-server.irouter.md) > [handleLegacyErrors](./kibana-plugin-server.irouter.handlelegacyerrors.md) - -## IRouter.handleLegacyErrors property - -Wrap a router handler to catch and converts legacy boom errors to proper custom errors. - -Signature: - -```typescript -(handler: RequestHandler) => RequestHandler; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IRouter](./kibana-plugin-server.irouter.md) > [handleLegacyErrors](./kibana-plugin-server.irouter.handlelegacyerrors.md) + +## IRouter.handleLegacyErrors property + +Wrap a router handler to catch and converts legacy boom errors to proper custom errors. + +Signature: + +```typescript +handleLegacyErrors: (handler: RequestHandler) => RequestHandler; +``` diff --git a/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.md b/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.md new file mode 100644 index 00000000000000..2e6daa58db25fa --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IScopedRenderingClient](./kibana-plugin-server.iscopedrenderingclient.md) + +## IScopedRenderingClient interface + + +Signature: + +```typescript +export interface IScopedRenderingClient +``` + +## Methods + +| Method | Description | +| --- | --- | +| [render(options)](./kibana-plugin-server.iscopedrenderingclient.render.md) | Generate a KibanaResponse which renders an HTML page bootstrapped with the core bundle. Intended as a response body for HTTP route handlers. | + diff --git a/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.render.md b/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.render.md new file mode 100644 index 00000000000000..1bc78dd84571d7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.iscopedrenderingclient.render.md @@ -0,0 +1,41 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IScopedRenderingClient](./kibana-plugin-server.iscopedrenderingclient.md) > [render](./kibana-plugin-server.iscopedrenderingclient.render.md) + +## IScopedRenderingClient.render() method + +Generate a `KibanaResponse` which renders an HTML page bootstrapped with the `core` bundle. Intended as a response body for HTTP route handlers. + +Signature: + +```typescript +render(options?: IRenderOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| options | IRenderOptions | | + +Returns: + +`Promise` + +## Example + + +```ts +router.get( + { path: '/', validate: false }, + (context, request, response) => + response.ok({ + body: await context.core.rendering.render(), + headers: { + 'content-security-policy': context.core.http.csp.header, + }, + }) +); + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.core.md b/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.core.md index 09ebf1170715b8..c4c043a903d06c 100644 --- a/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.core.md +++ b/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.core.md @@ -7,7 +7,5 @@ Signature: ```typescript -core: InternalCoreSetup & { - plugins: PluginsServiceSetup; - }; +core: LegacyCoreSetup; ``` diff --git a/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.md b/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.md index 4475318522dfab..7961cedd2c0548 100644 --- a/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.md +++ b/docs/development/core/server/kibana-plugin-server.legacyservicesetupdeps.md @@ -18,6 +18,6 @@ export interface LegacyServiceSetupDeps | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-server.legacyservicesetupdeps.core.md) | InternalCoreSetup & {
plugins: PluginsServiceSetup;
} | | +| [core](./kibana-plugin-server.legacyservicesetupdeps.core.md) | LegacyCoreSetup | | | [plugins](./kibana-plugin-server.legacyservicesetupdeps.plugins.md) | Record<string, unknown> | | diff --git a/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.core.md b/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.core.md index c5cf473aaa01a0..47018f4594967f 100644 --- a/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.core.md +++ b/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.core.md @@ -7,7 +7,5 @@ Signature: ```typescript -core: InternalCoreStart & { - plugins: PluginsServiceStart; - }; +core: LegacyCoreStart; ``` diff --git a/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.md b/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.md index 801138b64e46a4..602fe5356d5251 100644 --- a/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.md +++ b/docs/development/core/server/kibana-plugin-server.legacyservicestartdeps.md @@ -18,6 +18,6 @@ export interface LegacyServiceStartDeps | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-server.legacyservicestartdeps.core.md) | InternalCoreStart & {
plugins: PluginsServiceStart;
} | | +| [core](./kibana-plugin-server.legacyservicestartdeps.core.md) | LegacyCoreStart | | | [plugins](./kibana-plugin-server.legacyservicestartdeps.plugins.md) | Record<string, unknown> | | diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 9c8aafb158bfdf..5e7f84c55244d4 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -70,7 +70,9 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IKibanaResponse](./kibana-plugin-server.ikibanaresponse.md) | A response data object, expected to returned as a result of [RequestHandler](./kibana-plugin-server.requesthandler.md) execution | | [IKibanaSocket](./kibana-plugin-server.ikibanasocket.md) | A tiny abstraction for TCP socket. | | [IndexSettingsDeprecationInfo](./kibana-plugin-server.indexsettingsdeprecationinfo.md) | | +| [IRenderOptions](./kibana-plugin-server.irenderoptions.md) | | | [IRouter](./kibana-plugin-server.irouter.md) | Registers route handlers for specified resource path and method. See [RouteConfig](./kibana-plugin-server.routeconfig.md) and [RequestHandler](./kibana-plugin-server.requesthandler.md) for more information about arguments to route registrations. | +| [IScopedRenderingClient](./kibana-plugin-server.iscopedrenderingclient.md) | | | [IUiSettingsClient](./kibana-plugin-server.iuisettingsclient.md) | Server-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. | | [KibanaRequestRoute](./kibana-plugin-server.kibanarequestroute.md) | Request specific route information exposed to a handler. | | [LegacyRequest](./kibana-plugin-server.legacyrequest.md) | | @@ -91,7 +93,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [PluginManifest](./kibana-plugin-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. | | [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) | | | [PluginsServiceStart](./kibana-plugin-server.pluginsservicestart.md) | | -| [RequestHandlerContext](./kibana-plugin-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients: - [savedObjects.client](./kibana-plugin-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [elasticsearch.dataClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | +| [RequestHandlerContext](./kibana-plugin-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients: - [rendering](./kibana-plugin-server.iscopedrenderingclient.md) - Rendering client which uses the data of the incoming request - [savedObjects.client](./kibana-plugin-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [elasticsearch.dataClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | | [RouteConfig](./kibana-plugin-server.routeconfig.md) | Route specific configuration. | | [RouteConfigOptions](./kibana-plugin-server.routeconfigoptions.md) | Additional route options. | | [RouteConfigOptionsBody](./kibana-plugin-server.routeconfigoptionsbody.md) | Additional body options for a route | diff --git a/docs/development/core/server/kibana-plugin-server.requesthandlercontext.core.md b/docs/development/core/server/kibana-plugin-server.requesthandlercontext.core.md index 2d8b27ecb6c670..d1760dafd5bb63 100644 --- a/docs/development/core/server/kibana-plugin-server.requesthandlercontext.core.md +++ b/docs/development/core/server/kibana-plugin-server.requesthandlercontext.core.md @@ -8,6 +8,7 @@ ```typescript core: { + rendering: IScopedRenderingClient; savedObjects: { client: SavedObjectsClientContract; }; diff --git a/docs/development/core/server/kibana-plugin-server.requesthandlercontext.md b/docs/development/core/server/kibana-plugin-server.requesthandlercontext.md index d9b781e1e550ec..7c8625a5824ee8 100644 --- a/docs/development/core/server/kibana-plugin-server.requesthandlercontext.md +++ b/docs/development/core/server/kibana-plugin-server.requesthandlercontext.md @@ -6,7 +6,7 @@ Plugin specific context passed to a route handler. -Provides the following clients: - [savedObjects.client](./kibana-plugin-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [elasticsearch.dataClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request +Provides the following clients: - [rendering](./kibana-plugin-server.iscopedrenderingclient.md) - Rendering client which uses the data of the incoming request - [savedObjects.client](./kibana-plugin-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [elasticsearch.dataClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.adminClient](./kibana-plugin-server.scopedclusterclient.md) - Elasticsearch admin client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request Signature: @@ -18,5 +18,5 @@ export interface RequestHandlerContext | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
};
elasticsearch: {
dataClient: IScopedClusterClient;
adminClient: IScopedClusterClient;
};
uiSettings: {
client: IUiSettingsClient;
};
} | | +| [core](./kibana-plugin-server.requesthandlercontext.core.md) | {
rendering: IScopedRenderingClient;
savedObjects: {
client: SavedObjectsClientContract;
};
elasticsearch: {
dataClient: IScopedClusterClient;
adminClient: IScopedClusterClient;
};
uiSettings: {
client: IUiSettingsClient;
};
} | | diff --git a/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md b/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md index 4fbcf0981f1140..23a72fc3c68b3a 100644 --- a/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md +++ b/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md @@ -1,62 +1,62 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteConfig](./kibana-plugin-server.routeconfig.md) > [validate](./kibana-plugin-server.routeconfig.validate.md) - -## RouteConfig.validate property - -A schema created with `@kbn/config-schema` that every request will be validated against. - -Signature: - -```typescript -RouteValidatorFullConfig | false; -``` - -## Remarks - -You \*must\* specify a validation schema to be able to read: - url path segments - request query - request body To opt out of validating the request, specify `validate: false`. In this case request params, query, and body will be \*\*empty\*\* objects and have no access to raw values. In some cases you may want to use another validation library. To do this, you need to instruct the `@kbn/config-schema` library to output \*\*non-validated values\*\* with setting schema as `schema.object({}, { allowUnknowns: true })`; - -## Example - - -```ts - import { schema } from '@kbn/config-schema'; - router.get({ - path: 'path/{id}', - validate: { - params: schema.object({ - id: schema.string(), - }), - query: schema.object({...}), - body: schema.object({...}), - }, -}, -(context, req, res,) { - req.params; // type Readonly<{id: string}> - console.log(req.params.id); // value -}); - -router.get({ - path: 'path/{id}', - validate: false, // handler has no access to params, query, body values. -}, -(context, req, res,) { - req.params; // type Readonly<{}>; - console.log(req.params.id); // undefined -}); - -router.get({ - path: 'path/{id}', - validate: { - // handler has access to raw non-validated params in runtime - params: schema.object({}, { allowUnknowns: true }) - }, -}, -(context, req, res,) { - req.params; // type Readonly<{}>; - console.log(req.params.id); // value - myValidationLibrary.validate({ params: req.params }); -}); - -``` - + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteConfig](./kibana-plugin-server.routeconfig.md) > [validate](./kibana-plugin-server.routeconfig.validate.md) + +## RouteConfig.validate property + +A schema created with `@kbn/config-schema` that every request will be validated against. + +Signature: + +```typescript +validate: RouteValidatorFullConfig | false; +``` + +## Remarks + +You \*must\* specify a validation schema to be able to read: - url path segments - request query - request body To opt out of validating the request, specify `validate: false`. In this case request params, query, and body will be \*\*empty\*\* objects and have no access to raw values. In some cases you may want to use another validation library. To do this, you need to instruct the `@kbn/config-schema` library to output \*\*non-validated values\*\* with setting schema as `schema.object({}, { allowUnknowns: true })`; + +## Example + + +```ts + import { schema } from '@kbn/config-schema'; + router.get({ + path: 'path/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + query: schema.object({...}), + body: schema.object({...}), + }, +}, +(context, req, res,) { + req.params; // type Readonly<{id: string}> + console.log(req.params.id); // value +}); + +router.get({ + path: 'path/{id}', + validate: false, // handler has no access to params, query, body values. +}, +(context, req, res,) { + req.params; // type Readonly<{}>; + console.log(req.params.id); // undefined +}); + +router.get({ + path: 'path/{id}', + validate: { + // handler has access to raw non-validated params in runtime + params: schema.object({}, { allowUnknowns: true }) + }, +}, +(context, req, res,) { + req.params; // type Readonly<{}>; + console.log(req.params.id); // value + myValidationLibrary.validate({ params: req.params }); +}); + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.routevalidationerror._constructor_.md b/docs/development/core/server/kibana-plugin-server.routevalidationerror._constructor_.md index 31dc6ceb919952..551e13faaf1542 100644 --- a/docs/development/core/server/kibana-plugin-server.routevalidationerror._constructor_.md +++ b/docs/development/core/server/kibana-plugin-server.routevalidationerror._constructor_.md @@ -1,21 +1,21 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationError](./kibana-plugin-server.routevalidationerror.md) > [(constructor)](./kibana-plugin-server.routevalidationerror._constructor_.md) - -## RouteValidationError.(constructor) - -Constructs a new instance of the `RouteValidationError` class - -Signature: - -```typescript -constructor(error;: Error | string, path?: string[];) -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| error | Error | string | | -| path | string[] | | - + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationError](./kibana-plugin-server.routevalidationerror.md) > [(constructor)](./kibana-plugin-server.routevalidationerror._constructor_.md) + +## RouteValidationError.(constructor) + +Constructs a new instance of the `RouteValidationError` class + +Signature: + +```typescript +constructor(error: Error | string, path?: string[]); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | string | | +| path | string[] | | + diff --git a/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.badrequest.md b/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.badrequest.md index 2462ae17943bee..36ea6103fb352d 100644 --- a/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.badrequest.md +++ b/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.badrequest.md @@ -1,13 +1,13 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationResultFactory](./kibana-plugin-server.routevalidationresultfactory.md) > [badRequest](./kibana-plugin-server.routevalidationresultfactory.badrequest.md) - -## RouteValidationResultFactory.badRequest property - -Signature: - -```typescript -(error: Error | string, path?: string[]) => { - RouteValidationError; - }; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationResultFactory](./kibana-plugin-server.routevalidationresultfactory.md) > [badRequest](./kibana-plugin-server.routevalidationresultfactory.badrequest.md) + +## RouteValidationResultFactory.badRequest property + +Signature: + +```typescript +badRequest: (error: Error | string, path?: string[]) => { + error: RouteValidationError; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.ok.md b/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.ok.md index c86ef616de1030..eca6a31bd547f5 100644 --- a/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.ok.md +++ b/docs/development/core/server/kibana-plugin-server.routevalidationresultfactory.ok.md @@ -1,13 +1,13 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationResultFactory](./kibana-plugin-server.routevalidationresultfactory.md) > [ok](./kibana-plugin-server.routevalidationresultfactory.ok.md) - -## RouteValidationResultFactory.ok property - -Signature: - -```typescript -(value: T) => { - T; - }; -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidationResultFactory](./kibana-plugin-server.routevalidationresultfactory.md) > [ok](./kibana-plugin-server.routevalidationresultfactory.ok.md) + +## RouteValidationResultFactory.ok property + +Signature: + +```typescript +ok: (value: T) => { + value: T; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.routevalidatoroptions.unsafe.md b/docs/development/core/server/kibana-plugin-server.routevalidatoroptions.unsafe.md index b1c75e6dbdf67c..0406a372c4e9d7 100644 --- a/docs/development/core/server/kibana-plugin-server.routevalidatoroptions.unsafe.md +++ b/docs/development/core/server/kibana-plugin-server.routevalidatoroptions.unsafe.md @@ -1,18 +1,17 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidatorOptions](./kibana-plugin-server.routevalidatoroptions.md) > [unsafe](./kibana-plugin-server.routevalidatoroptions.unsafe.md) - -## RouteValidatorOptions.unsafe property - -Set the `unsafe` config to avoid running some additional internal \*safe\* validations on top of your custom validation - -Signature: - -```typescript -unsafe?: { - params?: boolean; - query?: boolean; - body?: boolean; - } - -``` + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteValidatorOptions](./kibana-plugin-server.routevalidatoroptions.md) > [unsafe](./kibana-plugin-server.routevalidatoroptions.unsafe.md) + +## RouteValidatorOptions.unsafe property + +Set the `unsafe` config to avoid running some additional internal \*safe\* validations on top of your custom validation + +Signature: + +```typescript +unsafe?: { + params?: boolean; + query?: boolean; + body?: boolean; + }; +``` diff --git a/package.json b/package.json index 651ffb60d7b887..a0f5dd3af14c00 100644 --- a/package.json +++ b/package.json @@ -234,7 +234,7 @@ "react-resize-detector": "^4.2.0", "react-router-dom": "^5.1.2", "react-sizeme": "^2.3.6", - "react-use": "^13.10.2", + "react-use": "^13.13.0", "reactcss": "1.2.3", "redux": "4.0.0", "redux-actions": "2.2.1", diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index d97f7485fb4d22..3fa4bdcbc5fa5e 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -26,7 +26,7 @@ import { first, mapTo, filter, map, take } from 'rxjs/operators'; import { REPO_ROOT } from '@kbn/dev-utils'; import { FSWatcher } from 'chokidar'; -import { LegacyConfig } from '../../core/server/legacy/config'; +import { LegacyConfig } from '../../core/server/legacy'; import { BasePathProxyServer } from '../../core/server/http'; // @ts-ignore diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index a2db7552246362..b2e2161c92cc8e 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -20,15 +20,13 @@ import { Subject } from 'rxjs'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; -import { ApplicationService } from './application_service'; import { ApplicationSetup, InternalApplicationStart, ApplicationStart, InternalApplicationSetup, } from './types'; - -type ApplicationServiceContract = PublicMethodsOf; +import { ApplicationServiceContract } from './test_types'; const createSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), @@ -41,23 +39,27 @@ const createInternalSetupContractMock = (): jest.Mocked => ({ +const createStartContractMock = (): jest.Mocked => ({ capabilities: capabilitiesServiceMock.createStartContract().capabilities, navigateToApp: jest.fn(), getUrlForApp: jest.fn(), registerMountContext: jest.fn(), }); -const createInternalStartContractMock = (): jest.Mocked => ({ - availableApps: new Map(), - availableLegacyApps: new Map(), - capabilities: capabilitiesServiceMock.createStartContract().capabilities, - navigateToApp: jest.fn(), - getUrlForApp: jest.fn(), - registerMountContext: jest.fn(), - currentAppId$: new Subject(), - getComponent: jest.fn(), -}); +const createInternalStartContractMock = (): jest.Mocked => { + const currentAppId$ = new Subject(); + + return { + availableApps: new Map(), + availableLegacyApps: new Map(), + capabilities: capabilitiesServiceMock.createStartContract().capabilities, + currentAppId$: currentAppId$.asObservable(), + getComponent: jest.fn(), + getUrlForApp: jest.fn(), + navigateToApp: jest.fn().mockImplementation(appId => currentAppId$.next(appId)), + registerMountContext: jest.fn(), + }; +}; const createMock = (): jest.Mocked => ({ setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), @@ -69,7 +71,6 @@ export const applicationServiceMock = { create: createMock, createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, - createInternalSetupContract: createInternalSetupContractMock, createInternalStartContract: createInternalStartContractMock, }; diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts new file mode 100644 index 00000000000000..d064b17ace1423 --- /dev/null +++ b/src/core/public/application/application_service.test.ts @@ -0,0 +1,441 @@ +/* + * 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 { createElement } from 'react'; +import { Subject } from 'rxjs'; +import { bufferCount, skip, takeUntil } from 'rxjs/operators'; +import { shallow } from 'enzyme'; + +import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; +import { contextServiceMock } from '../context/context_service.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; +import { MockLifecycle } from './test_types'; +import { ApplicationService } from './application_service'; + +function mount() {} + +describe('#setup()', () => { + let setupDeps: MockLifecycle<'setup'>; + let startDeps: MockLifecycle<'start'>; + let service: ApplicationService; + + beforeEach(() => { + const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps = { + http, + context: contextServiceMock.createSetupContract(), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + }; + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); + startDeps = { http, injectedMetadata: setupDeps.injectedMetadata }; + service = new ApplicationService(); + }); + + describe('register', () => { + it('throws an error if two apps with the same id are registered', () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + expect(() => + register(Symbol(), { id: 'app1', mount } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the id \\"app1\\""` + ); + }); + + it('throws error if additional apps are registered after setup', async () => { + const { register } = service.setup(setupDeps); + + await service.start(startDeps); + expect(() => + register(Symbol(), { id: 'app1', mount } as any) + ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); + }); + + it('throws an error if an App with the same appRoute is registered', () => { + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + + expect(() => + register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the appRoute \\"/app/app1\\""` + ); + expect(() => registerLegacyApp({ id: 'app1' } as any)).not.toThrow(); + + register(Symbol(), { id: 'app-next', mount, appRoute: '/app/app3' } as any); + + expect(() => + register(Symbol(), { id: 'app2', mount, appRoute: '/app/app3' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the appRoute \\"/app/app3\\""` + ); + expect(() => registerLegacyApp({ id: 'app3' } as any)).not.toThrow(); + }); + + it('throws an error if an App starts with the HTTP base path', () => { + const { register } = service.setup(setupDeps); + + expect(() => + register(Symbol(), { id: 'app2', mount, appRoute: '/test/app2' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot register an application route that includes HTTP base path"` + ); + }); + }); + + describe('registerLegacyApp', () => { + it('throws an error if two apps with the same id are registered', () => { + const { registerLegacyApp } = service.setup(setupDeps); + + registerLegacyApp({ id: 'app2' } as any); + expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( + `"A legacy application is already registered with the id \\"app2\\""` + ); + }); + + it('throws error if additional apps are registered after setup', async () => { + const { registerLegacyApp } = service.setup(setupDeps); + + await service.start(startDeps); + expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( + `"Applications cannot be registered after \\"setup\\""` + ); + }); + + it('throws an error if a LegacyApp with the same appRoute is registered', () => { + const { register, registerLegacyApp } = service.setup(setupDeps); + + registerLegacyApp({ id: 'app1' } as any); + + expect(() => + register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the appRoute \\"/app/app1\\""` + ); + expect(() => registerLegacyApp({ id: 'app1:other' } as any)).not.toThrow(); + }); + }); + + it("`registerMountContext` calls context container's registerContext", () => { + const { registerMountContext } = service.setup(setupDeps); + const container = setupDeps.context.createContextContainer.mock.results[0].value; + const pluginId = Symbol(); + + registerMountContext(pluginId, 'test' as any, mount as any); + expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount); + }); +}); + +describe('#start()', () => { + let setupDeps: MockLifecycle<'setup'>; + let startDeps: MockLifecycle<'start'>; + let service: ApplicationService; + + beforeEach(() => { + MockHistory.push.mockReset(); + const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps = { + http, + context: contextServiceMock.createSetupContract(), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + }; + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); + startDeps = { http, injectedMetadata: setupDeps.injectedMetadata }; + service = new ApplicationService(); + }); + + it('rejects if called prior to #setup()', async () => { + await expect(service.start(startDeps)).rejects.toThrowErrorMatchingInlineSnapshot( + `"ApplicationService#setup() must be invoked before start."` + ); + }); + + it('exposes available apps', async () => { + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + registerLegacyApp({ id: 'app2' } as any); + + const { availableApps, availableLegacyApps } = await service.start(startDeps); + + expect(availableApps).toMatchInlineSnapshot(` + Map { + "app1" => Object { + "appRoute": "/app/app1", + "id": "app1", + "mount": [Function], + }, + } + `); + expect(availableLegacyApps).toMatchInlineSnapshot(` + Map { + "app2" => Object { + "id": "app2", + }, + } + `); + }); + + it('passes appIds to capabilities', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + register(Symbol(), { id: 'app2', mount } as any); + register(Symbol(), { id: 'app3', mount } as any); + await service.start(startDeps); + + expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ + appIds: ['app1', 'app2', 'app3'], + http: setupDeps.http, + }); + }); + + it('filters available applications based on capabilities', async () => { + MockCapabilitiesService.start.mockResolvedValueOnce({ + capabilities: { + navLinks: { + app1: true, + app2: false, + legacyApp1: true, + legacyApp2: false, + }, + }, + } as any); + + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + registerLegacyApp({ id: 'legacyApp1' } as any); + register(Symbol(), { id: 'app2', mount } as any); + registerLegacyApp({ id: 'legacyApp2' } as any); + + const { availableApps, availableLegacyApps } = await service.start(startDeps); + + expect(availableApps).toMatchInlineSnapshot(` + Map { + "app1" => Object { + "appRoute": "/app/app1", + "id": "app1", + "mount": [Function], + }, + } + `); + expect(availableLegacyApps).toMatchInlineSnapshot(` + Map { + "legacyApp1" => Object { + "id": "legacyApp1", + }, + } + `); + }); + + describe('getComponent', () => { + it('returns renderable JSX tree', async () => { + service.setup(setupDeps); + + const { getComponent } = await service.start(startDeps); + + expect(() => shallow(createElement(getComponent))).not.toThrow(); + expect(getComponent()).toMatchInlineSnapshot(` + + `); + }); + + it('renders null when in legacy mode', async () => { + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + service.setup(setupDeps); + + const { getComponent } = await service.start(startDeps); + + expect(() => shallow(createElement(getComponent))).not.toThrow(); + expect(getComponent()).toBe(null); + }); + }); + + describe('getUrlForApp', () => { + it('creates URL for unregistered appId', async () => { + service.setup(setupDeps); + + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1')).toBe('/app/app1'); + }); + + it('creates URL for registered appId', async () => { + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + registerLegacyApp({ id: 'legacyApp1' } as any); + register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1')).toBe('/app/app1'); + expect(getUrlForApp('legacyApp1')).toBe('/app/legacyApp1'); + expect(getUrlForApp('app2')).toBe('/custom/path'); + }); + + it('creates URLs with path parameter', async () => { + service.setup(setupDeps); + + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1', { path: 'deep/link' })).toBe('/app/app1/deep/link'); + expect(getUrlForApp('app1', { path: '/deep//link/' })).toBe('/app/app1/deep/link'); + expect(getUrlForApp('app1', { path: '//deep/link//' })).toBe('/app/app1/deep/link'); + expect(getUrlForApp('app1', { path: 'deep/link///' })).toBe('/app/app1/deep/link'); + }); + }); + + describe('navigateToApp', () => { + it('changes the browser history to /app/:appId', async () => { + service.setup(setupDeps); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); + + navigateToApp('myOtherApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined); + }); + + it('changes the browser history for custom appRoutes', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); + + navigateToApp('app2'); + expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', undefined); + }); + + it('appends a path if specified', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp', { path: 'deep/link/to/location/2' }); + expect(MockHistory.push).toHaveBeenCalledWith( + '/app/myTestApp/deep/link/to/location/2', + undefined + ); + + navigateToApp('app2', { path: 'deep/link/to/location/2' }); + expect(MockHistory.push).toHaveBeenCalledWith( + '/custom/path/deep/link/to/location/2', + undefined + ); + }); + + it('includes state if specified', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp', { state: 'my-state' }); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state'); + + navigateToApp('app2', { state: 'my-state' }); + expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', 'my-state'); + }); + + it('redirects when in legacyMode', async () => { + setupDeps.redirectTo = jest.fn(); + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + service.setup(setupDeps); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp'); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/myTestApp'); + }); + + it('updates currentApp$ after mounting', async () => { + service.setup(setupDeps); + + const { currentAppId$, navigateToApp } = await service.start(startDeps); + const stop$ = new Subject(); + const promise = currentAppId$.pipe(skip(1), bufferCount(4), takeUntil(stop$)).toPromise(); + + await navigateToApp('alpha'); + await navigateToApp('beta'); + await navigateToApp('gamma'); + await navigateToApp('delta'); + stop$.next(); + + const appIds = await promise; + + expect(appIds).toMatchInlineSnapshot(` + Array [ + "alpha", + "beta", + "gamma", + "delta", + ] + `); + }); + + it('sets window.location.href when navigating to legacy apps', async () => { + setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + setupDeps.redirectTo = jest.fn(); + service.setup(setupDeps); + + const { navigateToApp } = await service.start(startDeps); + + await navigateToApp('alpha'); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/alpha'); + }); + + it('handles legacy apps with subapps', async () => { + setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + setupDeps.redirectTo = jest.fn(); + + const { registerLegacyApp } = service.setup(setupDeps); + + registerLegacyApp({ id: 'baseApp:legacyApp1' } as any); + + const { navigateToApp } = await service.start(startDeps); + + await navigateToApp('baseApp:legacyApp1'); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/baseApp'); + }); + }); +}); diff --git a/src/core/public/application/application_service.test.tsx b/src/core/public/application/application_service.test.tsx deleted file mode 100644 index 32634572466a67..00000000000000 --- a/src/core/public/application/application_service.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -/* - * 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 { shallow } from 'enzyme'; -import React from 'react'; - -import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; -import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; -import { ApplicationService } from './application_service'; -import { contextServiceMock } from '../context/context_service.mock'; -import { httpServiceMock } from '../http/http_service.mock'; - -describe('#setup()', () => { - describe('register', () => { - it('throws an error if two apps with the same id are registered', () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.register(Symbol(), { id: 'app1', mount: jest.fn() } as any); - expect(() => - setup.register(Symbol(), { id: 'app1', mount: jest.fn() } as any) - ).toThrowErrorMatchingInlineSnapshot( - `"An application is already registered with the id \\"app1\\""` - ); - }); - - it('throws error if additional apps are registered after setup', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - expect(() => - setup.register(Symbol(), { id: 'app1' } as any) - ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); - }); - - it('logs a warning when registering a deprecated app mount', async () => { - const consoleWarnSpy = jest.spyOn(console, 'warn'); - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.register(Symbol(), { id: 'app1', mount: (ctx: any, params: any) => {} } as any); - expect(consoleWarnSpy).toHaveBeenCalledWith( - `App [app1] is using deprecated mount context. Use core.getStartServices() instead.` - ); - consoleWarnSpy.mockRestore(); - }); - }); - - describe('registerLegacyApp', () => { - it('throws an error if two apps with the same id are registered', () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.registerLegacyApp({ id: 'app2' } as any); - expect(() => - setup.registerLegacyApp({ id: 'app2' } as any) - ).toThrowErrorMatchingInlineSnapshot( - `"A legacy application is already registered with the id \\"app2\\""` - ); - }); - - it('throws error if additional apps are registered after setup', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - expect(() => - setup.registerLegacyApp({ id: 'app2' } as any) - ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); - }); - }); - - it("`registerMountContext` calls context container's registerContext", () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const container = context.createContextContainer.mock.results[0].value; - const pluginId = Symbol(); - const noop = () => {}; - setup.registerMountContext(pluginId, 'test' as any, noop as any); - expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', noop); - }); -}); - -describe('#start()', () => { - beforeEach(() => { - MockHistory.push.mockReset(); - }); - - it('exposes available apps from capabilities', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.register(Symbol(), { id: 'app1', mount: jest.fn() } as any); - setup.registerLegacyApp({ id: 'app2' } as any); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - const startContract = await service.start({ http, injectedMetadata }); - - expect(startContract.availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "id": "app1", - "mount": [MockFunction], - }, - } - `); - expect(startContract.availableLegacyApps).toMatchInlineSnapshot(` - Map { - "app2" => Object { - "id": "app2", - }, - } - `); - }); - - it('passes registered applications to capabilities', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const app1 = { id: 'app1', mount: jest.fn() }; - setup.register(Symbol(), app1 as any); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - - expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ - apps: new Map([['app1', app1]]), - legacyApps: new Map(), - http, - }); - }); - - it('passes registered legacy applications to capabilities', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.registerLegacyApp({ id: 'legacyApp1' } as any); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - - expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ - apps: new Map(), - legacyApps: new Map([['legacyApp1', { id: 'legacyApp1' }]]), - http, - }); - }); - - it('returns renderable JSX tree', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - expect(() => shallow(React.createElement(() => start.getComponent()))).not.toThrow(); - }); - - describe('navigateToApp', () => { - it('changes the browser history to /app/:appId', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - start.navigateToApp('myTestApp'); - expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); - start.navigateToApp('myOtherApp'); - expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined); - }); - - it('appends a path if specified', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - start.navigateToApp('myTestApp', { path: 'deep/link/to/location/2' }); - expect(MockHistory.push).toHaveBeenCalledWith( - '/app/myTestApp/deep/link/to/location/2', - undefined - ); - }); - - it('includes state if specified', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - start.navigateToApp('myTestApp', { state: 'my-state' }); - expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state'); - }); - - it('redirects when in legacyMode', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(true); - const redirectTo = jest.fn(); - const start = await service.start({ http, injectedMetadata, redirectTo }); - start.navigateToApp('myTestApp'); - expect(redirectTo).toHaveBeenCalledWith('/app/myTestApp'); - }); - }); -}); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index df00c84028e6f4..a96b9dea9b9c71 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -17,31 +17,32 @@ * under the License. */ -import { createBrowserHistory } from 'history'; -import { BehaviorSubject } from 'rxjs'; import React from 'react'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { createBrowserHistory, History } from 'history'; -import { InjectedMetadataStart } from '../injected_metadata'; -import { CapabilitiesService } from './capabilities'; -import { AppRouter } from './ui'; -import { HttpStart } from '../http'; +import { InjectedMetadataSetup, InjectedMetadataStart } from '../injected_metadata'; +import { HttpSetup, HttpStart } from '../http'; import { ContextSetup, IContextContainer } from '../context'; +import { AppRouter } from './ui'; +import { CapabilitiesService, Capabilities } from './capabilities'; import { App, LegacyApp, AppMount, AppMountDeprecated, + AppMounter, + LegacyAppMounter, + Mounter, InternalApplicationSetup, InternalApplicationStart, } from './types'; interface SetupDeps { context: ContextSetup; -} - -interface StartDeps { - http: HttpStart; - injectedMetadata: InjectedMetadataStart; + http: HttpSetup; + injectedMetadata: InjectedMetadataSetup; /** * Only necessary for redirecting to legacy apps * @deprecated @@ -49,144 +50,158 @@ interface StartDeps { redirectTo?: (path: string) => void; } -interface AppBox { - app: App; - mount: AppMount; +interface StartDeps { + injectedMetadata: InjectedMetadataStart; + http: HttpStart; } +// Mount functions with two arguments are assumed to expect deprecated `context` object. +const isAppMountDeprecated = (mount: (...args: any[]) => any): mount is AppMountDeprecated => + mount.length === 2; +const filterAvailable = (map: Map, capabilities: Capabilities) => + new Map( + [...map].filter( + ([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true + ) + ); +const findMounter = (mounters: Map, appRoute?: string) => + [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); +const getAppUrl = (mounters: Map, appId: string, path: string = '') => + `/${mounters.get(appId)?.appRoute ?? `/app/${appId}`}/${path}` + .replace(/\/{2,}/g, '/') // Remove duplicate slashes + .replace(/\/$/, ''); // Remove trailing slash + /** * Service that is responsible for registering new applications. * @internal */ export class ApplicationService { - private readonly apps$ = new BehaviorSubject>(new Map()); - private readonly legacyApps$ = new BehaviorSubject>(new Map()); + private readonly apps = new Map(); + private readonly legacyApps = new Map(); + private readonly mounters = new Map(); private readonly capabilities = new CapabilitiesService(); + private currentAppId$ = new BehaviorSubject(undefined); + private stop$ = new Subject(); + private registrationClosed = false; + private history?: History; private mountContext?: IContextContainer; + private navigate?: (url: string, state: any) => void; - public setup({ context }: SetupDeps): InternalApplicationSetup { + public setup({ + context, + http: { basePath }, + injectedMetadata, + redirectTo = (path: string) => (window.location.href = path), + }: SetupDeps): InternalApplicationSetup { + const basename = basePath.get(); + // Only setup history if we're not in legacy mode + if (!injectedMetadata.getLegacyMode()) { + this.history = createBrowserHistory({ basename }); + } + + // If we do not have history available, use redirectTo to do a full page refresh. + this.navigate = (url, state) => + // basePath not needed here because `history` is configured with basename + this.history ? this.history.push(url, state) : redirectTo(basePath.prepend(url)); this.mountContext = context.createContextContainer(); return { - register: (plugin: symbol, app: App) => { - if (this.apps$.value.has(app.id)) { - throw new Error(`An application is already registered with the id "${app.id}"`); - } - if (this.apps$.isStopped) { + registerMountContext: this.mountContext!.registerContext, + register: (plugin, app) => { + app = { appRoute: `/app/${app.id}`, ...app }; + + if (this.registrationClosed) { throw new Error(`Applications cannot be registered after "setup"`); + } else if (this.apps.has(app.id)) { + throw new Error(`An application is already registered with the id "${app.id}"`); + } else if (findMounter(this.mounters, app.appRoute)) { + throw new Error( + `An application is already registered with the appRoute "${app.appRoute}"` + ); + } else if (basename && app.appRoute!.startsWith(basename)) { + throw new Error('Cannot register an application route that includes HTTP base path'); } - let appBox: AppBox; + let handler: AppMount; + if (isAppMountDeprecated(app.mount)) { + handler = this.mountContext!.createHandler(plugin, app.mount); // eslint-disable-next-line no-console console.warn( `App [${app.id}] is using deprecated mount context. Use core.getStartServices() instead.` ); - - appBox = { - app, - mount: this.mountContext!.createHandler(plugin, app.mount), - }; } else { - appBox = { app, mount: app.mount }; + handler = app.mount; } - this.apps$.next(new Map([...this.apps$.value.entries(), [app.id, appBox]])); + const mount: AppMounter = async params => { + const unmount = await handler(params); + this.currentAppId$.next(app.id); + return unmount; + }; + this.apps.set(app.id, app); + this.mounters.set(app.id, { + appRoute: app.appRoute!, + appBasePath: basePath.prepend(app.appRoute!), + mount, + unmountBeforeMounting: false, + }); }, - registerLegacyApp: (app: LegacyApp) => { - if (this.legacyApps$.value.has(app.id)) { + registerLegacyApp: app => { + const appRoute = `/app/${app.id.split(':')[0]}`; + + if (this.registrationClosed) { + throw new Error('Applications cannot be registered after "setup"'); + } else if (this.legacyApps.has(app.id)) { throw new Error(`A legacy application is already registered with the id "${app.id}"`); - } - if (this.legacyApps$.isStopped) { - throw new Error(`Applications cannot be registered after "setup"`); + } else if (basename && appRoute!.startsWith(basename)) { + throw new Error('Cannot register an application route that includes HTTP base path'); } - this.legacyApps$.next(new Map([...this.legacyApps$.value.entries(), [app.id, app]])); + const appBasePath = basePath.prepend(appRoute); + const mount: LegacyAppMounter = () => redirectTo(appBasePath); + this.legacyApps.set(app.id, app); + this.mounters.set(app.id, { + appRoute, + appBasePath, + mount, + unmountBeforeMounting: true, + }); }, - registerMountContext: this.mountContext!.registerContext, }; } - public async start({ - http, - injectedMetadata, - redirectTo = (path: string) => (window.location.href = path), - }: StartDeps): Promise { + public async start({ injectedMetadata, http }: StartDeps): Promise { if (!this.mountContext) { - throw new Error(`ApplicationService#setup() must be invoked before start.`); + throw new Error('ApplicationService#setup() must be invoked before start.'); } - // Disable registration of new applications - this.apps$.complete(); - this.legacyApps$.complete(); - - const legacyMode = injectedMetadata.getLegacyMode(); - const currentAppId$ = new BehaviorSubject(undefined); - const { availableApps, availableLegacyApps, capabilities } = await this.capabilities.start({ + this.registrationClosed = true; + const { capabilities } = await this.capabilities.start({ + appIds: [...this.mounters.keys()], http, - apps: new Map([...this.apps$.value].map(([id, { app }]) => [id, app])), - legacyApps: this.legacyApps$.value, }); - - // Only setup history if we're not in legacy mode - const history = legacyMode ? null : createBrowserHistory({ basename: http.basePath.get() }); + const availableMounters = filterAvailable(this.mounters, capabilities); return { - availableApps, - availableLegacyApps, + availableApps: filterAvailable(this.apps, capabilities), + availableLegacyApps: filterAvailable(this.legacyApps, capabilities), capabilities, + currentAppId$: this.currentAppId$.pipe(takeUntil(this.stop$)), registerMountContext: this.mountContext.registerContext, - currentAppId$, - - getUrlForApp: (appId, options: { path?: string } = {}) => { - return http.basePath.prepend(appPath(appId, options)); - }, - + getUrlForApp: (appId, { path }: { path?: string } = {}) => + getAppUrl(availableMounters, appId, path), navigateToApp: (appId, { path, state }: { path?: string; state?: any } = {}) => { - if (legacyMode) { - // If we're in legacy mode, do a full page refresh to load the NP app. - redirectTo(http.basePath.prepend(appPath(appId, { path }))); - } else { - // basePath not needed here because `history` is configured with basename - history!.push(appPath(appId, { path }), state); - } - }, - - getComponent: () => { - if (legacyMode) { - return null; - } - - // Filter only available apps and map to just the mount function. - const appMounts = new Map( - [...this.apps$.value] - .filter(([id]) => availableApps.has(id)) - .map(([id, { mount }]) => [id, mount]) - ); - - return ( - - ); + this.navigate!(getAppUrl(availableMounters, appId, path), state); + this.currentAppId$.next(appId); }, + getComponent: () => + this.history ? : null, }; } - public stop() {} -} - -const appPath = (appId: string, { path }: { path?: string } = {}): string => - path - ? `/app/${appId}/${path.replace(/^\//, '')}` // Remove preceding slash from path if present - : `/app/${appId}`; - -function isAppMountDeprecated(mount: (...args: any[]) => any): mount is AppMountDeprecated { - // Mount functions with two arguments are assumed to expect deprecated `context` object. - return mount.length === 2; + public stop() { + this.stop$.next(); + this.currentAppId$.complete(); + } } diff --git a/src/core/public/application/capabilities/capabilities_service.mock.ts b/src/core/public/application/capabilities/capabilities_service.mock.ts index 29c3275f0e3b27..54aaa31e08859e 100644 --- a/src/core/public/application/capabilities/capabilities_service.mock.ts +++ b/src/core/public/application/capabilities/capabilities_service.mock.ts @@ -17,15 +17,9 @@ * under the License. */ import { CapabilitiesService, CapabilitiesStart } from './capabilities_service'; -import { deepFreeze } from '../../../utils/'; -import { App, LegacyApp } from '../types'; +import { deepFreeze } from '../../../utils'; -const createStartContractMock = ( - apps: ReadonlyMap = new Map(), - legacyApps: ReadonlyMap = new Map() -): jest.Mocked => ({ - availableApps: apps, - availableLegacyApps: legacyApps, +const createStartContractMock = (): jest.Mocked => ({ capabilities: deepFreeze({ catalogue: {}, management: {}, @@ -33,11 +27,8 @@ const createStartContractMock = ( }), }); -type CapabilitiesServiceContract = PublicMethodsOf; -const createMock = (): jest.Mocked => ({ - start: jest - .fn() - .mockImplementation(({ apps, legacyApps }) => createStartContractMock(apps, legacyApps)), +const createMock = (): jest.Mocked> => ({ + start: jest.fn().mockImplementation(createStartContractMock), }); export const capabilitiesServiceMock = { diff --git a/src/core/public/application/capabilities/capabilities_service.test.ts b/src/core/public/application/capabilities/capabilities_service.test.ts index 3245be8dd502d9..dfbb449b4d58e6 100644 --- a/src/core/public/application/capabilities/capabilities_service.test.ts +++ b/src/core/public/application/capabilities/capabilities_service.test.ts @@ -19,7 +19,6 @@ import { httpServiceMock, HttpSetupMock } from '../../http/http_service.mock'; import { CapabilitiesService } from './capabilities_service'; -import { LegacyApp, App } from '../types'; const mockedCapabilities = { catalogue: {}, @@ -42,36 +41,22 @@ describe('#start', () => { http.post.mockReturnValue(Promise.resolve(mockedCapabilities)); }); - const apps = new Map([ - ['app1', { id: 'app1' }], - ['app2', { id: 'app2', capabilities: { app2: { feature: true } } }], - ['appMissingInCapabilities', { id: 'appMissingInCapabilities' }], - ] as Array<[string, App]>); - const legacyApps = new Map([ - ['legacyApp1', { id: 'legacyApp1' }], - ['legacyApp2', { id: 'legacyApp2', capabilities: { app2: { feature: true } } }], - ] as Array<[string, LegacyApp]>); - - it('filters available apps based on returned navLinks', async () => { + it('only returns capabilities for given appIds', async () => { const service = new CapabilitiesService(); - const startContract = await service.start({ apps, legacyApps, http }); - expect(startContract.availableApps).toEqual( - new Map([ - ['app1', { id: 'app1' }], - ['appMissingInCapabilities', { id: 'appMissingInCapabilities' }], - ]) - ); - expect(startContract.availableLegacyApps).toEqual( - new Map([['legacyApp1', { id: 'legacyApp1' }]]) - ); + const { capabilities } = await service.start({ + http, + appIds: ['app1', 'app2', 'legacyApp1', 'legacyApp2'], + }); + + // @ts-ignore TypeScript knows this shouldn't be possible + expect(() => (capabilities.foo = 'foo')).toThrowError(); }); it('does not allow Capabilities to be modified', async () => { const service = new CapabilitiesService(); const { capabilities } = await service.start({ - apps, - legacyApps, http, + appIds: ['app1', 'app2', 'legacyApp1', 'legacyApp2'], }); // @ts-ignore TypeScript knows this shouldn't be possible diff --git a/src/core/public/application/capabilities/capabilities_service.tsx b/src/core/public/application/capabilities/capabilities_service.tsx index 24d9765953c443..05d718e1073dfa 100644 --- a/src/core/public/application/capabilities/capabilities_service.tsx +++ b/src/core/public/application/capabilities/capabilities_service.tsx @@ -19,22 +19,16 @@ import { Capabilities } from '../../../types/capabilities'; import { deepFreeze, RecursiveReadonly } from '../../../utils'; -import { LegacyApp, App } from '../types'; import { HttpStart } from '../../http'; interface StartDeps { - apps: ReadonlyMap; - legacyApps: ReadonlyMap; + appIds: string[]; http: HttpStart; } -export { Capabilities }; - /** @internal */ export interface CapabilitiesStart { capabilities: RecursiveReadonly; - availableApps: ReadonlyMap; - availableLegacyApps: ReadonlyMap; } /** @@ -42,41 +36,14 @@ export interface CapabilitiesStart { * @internal */ export class CapabilitiesService { - public async start({ apps, legacyApps, http }: StartDeps): Promise { - const capabilities = await this.fetchCapabilities(http, [...apps.keys(), ...legacyApps.keys()]); - - const availableApps = new Map( - [...apps].filter( - ([appId]) => - capabilities.navLinks[appId] === undefined || capabilities.navLinks[appId] === true - ) - ); - - const availableLegacyApps = new Map( - [...legacyApps].filter( - ([appId]) => - capabilities.navLinks[appId] === undefined || capabilities.navLinks[appId] === true - ) - ); + public async start({ appIds, http }: StartDeps): Promise { + const route = http.anonymousPaths.isAnonymous(window.location.pathname) ? '/defaults' : ''; + const capabilities = await http.post(`/api/core/capabilities${route}`, { + body: JSON.stringify({ applications: appIds }), + }); return { - availableApps, - availableLegacyApps, - capabilities, + capabilities: deepFreeze(capabilities), }; } - - private async fetchCapabilities(http: HttpStart, appIds: string[]): Promise { - const payload = JSON.stringify({ - applications: appIds, - }); - - const url = http.anonymousPaths.isAnonymous(window.location.pathname) - ? '/api/core/capabilities/defaults' - : '/api/core/capabilities'; - const capabilities = await http.post(url, { - body: payload, - }); - return deepFreeze(capabilities); - } } diff --git a/src/core/public/application/capabilities/index.ts b/src/core/public/application/capabilities/index.ts index 9d8bec955eb979..e4112a55ef6bd2 100644 --- a/src/core/public/application/capabilities/index.ts +++ b/src/core/public/application/capabilities/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export { Capabilities, CapabilitiesService } from './capabilities_service'; +export { Capabilities } from '../../../types/capabilities'; +export { CapabilitiesService } from './capabilities_service'; diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 81aef5204c7e29..10544c348afb03 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -18,107 +18,161 @@ */ import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; -import { createMemoryHistory, History } from 'history'; -import { BehaviorSubject } from 'rxjs'; +import { createMemoryHistory, History, createHashHistory } from 'history'; -import { I18nProvider } from '@kbn/i18n/react'; - -import { AppMount, LegacyApp, AppMountParameters } from '../types'; -import { httpServiceMock } from '../../http/http_service.mock'; import { AppRouter, AppNotFound } from '../ui'; - -const createMountHandler = (htmlString: string) => - jest.fn(async ({ appBasePath: basename, element: el }: AppMountParameters) => { - el.innerHTML = `
\nbasename: ${basename}\nhtml: ${htmlString}\n
`; - return jest.fn(() => (el.innerHTML = '')); - }); +import { EitherApp, MockedMounterMap, MockedMounterTuple } from '../test_types'; +import { createRenderer, createAppMounter, createLegacyAppMounter } from './utils'; describe('AppContainer', () => { - let apps: Map, Parameters>>; - let legacyApps: Map; + let mounters: MockedMounterMap; let history: History; - let router: ReactWrapper; - let redirectTo: jest.Mock; - let currentAppId$: BehaviorSubject; + let update: ReturnType; - const navigate = async (path: string) => { + const navigate = (path: string) => { history.push(path); - router.update(); - // flushes any pending promises - return new Promise(resolve => setImmediate(resolve)); + return update(); }; + const mockMountersToMounters = () => + new Map([...mounters].map(([appId, { mounter }]) => [appId, mounter])); + beforeEach(() => { - redirectTo = jest.fn(); - apps = new Map([ - ['app1', createMountHandler('App 1')], - ['app2', createMountHandler('
App 2
')], - ]); - legacyApps = new Map([ - ['legacyApp1', { id: 'legacyApp1' }], - ['baseApp:legacyApp2', { id: 'baseApp:legacyApp2' }], - ]) as Map; + mounters = new Map([ + createAppMounter('app1', 'App 1'), + createLegacyAppMounter('legacyApp1', jest.fn()), + createAppMounter('app2', '
App 2
'), + createLegacyAppMounter('baseApp:legacyApp2', jest.fn()), + createAppMounter('app3', '
App 3
', '/custom/path'), + ] as Array>); history = createMemoryHistory(); - currentAppId$ = new BehaviorSubject(undefined); - // Use 'asdf' as the basepath - const http = httpServiceMock.createStartContract({ basePath: '/asdf' }); - router = mount( - - - - ); + update = createRenderer(); }); - it('calls mountHandler and returned unmount function when navigating between apps', async () => { - await navigate('/app/app1'); - expect(apps.get('app1')!).toHaveBeenCalled(); - expect(router.html()).toMatchInlineSnapshot(` + it('calls mount handler and returned unmount function when navigating between apps', async () => { + const dom1 = await navigate('/app/app1'); + const app1 = mounters.get('app1')!; + + expect(app1.mounter.mount).toHaveBeenCalled(); + expect(dom1?.html()).toMatchInlineSnapshot(` "
- basename: /asdf/app/app1 + basename: /app/app1 html: App 1
" `); - const app1Unmount = await apps.get('app1')!.mock.results[0].value; - await navigate('/app/app2'); - expect(app1Unmount).toHaveBeenCalled(); + const app1Unmount = await app1.mounter.mount.mock.results[0].value; + const dom2 = await navigate('/app/app2'); - expect(apps.get('app2')!).toHaveBeenCalled(); - expect(router.html()).toMatchInlineSnapshot(` + expect(app1Unmount).toHaveBeenCalled(); + expect(mounters.get('app2')!.mounter.mount).toHaveBeenCalled(); + expect(dom2?.html()).toMatchInlineSnapshot(` "
- basename: /asdf/app/app2 + basename: /app/app2 html:
App 2
" `); }); - it('updates currentApp$ after mounting', async () => { - await navigate('/app/app1'); - expect(currentAppId$.value).toEqual('app1'); - await navigate('/app/app2'); - expect(currentAppId$.value).toEqual('app2'); + it('should not mount when partial route path matches', async () => { + mounters.set(...createAppMounter('spaces', '
Custom Space
', '/spaces/fake-login')); + mounters.set(...createAppMounter('login', '
Login Page
', '/fake-login')); + history = createMemoryHistory(); + update = createRenderer(); + + await navigate('/fake-login'); + + expect(mounters.get('spaces')!.mounter.mount).not.toHaveBeenCalled(); + expect(mounters.get('login')!.mounter.mount).toHaveBeenCalled(); + }); + + it('should not mount when partial route path has higher specificity', async () => { + mounters.set(...createAppMounter('login', '
Login Page
', '/fake-login')); + mounters.set(...createAppMounter('spaces', '
Custom Space
', '/spaces/fake-login')); + history = createMemoryHistory(); + update = createRenderer(); + + await navigate('/spaces/fake-login'); + + expect(mounters.get('spaces')!.mounter.mount).toHaveBeenCalled(); + expect(mounters.get('login')!.mounter.mount).not.toHaveBeenCalled(); }); - it('sets window.location.href when navigating to legacy apps', async () => { + it('should not remount when changing pages within app', async () => { + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Navigating to page within app does not trigger re-render + await navigate('/app/app1/page2'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + expect(unmount).not.toHaveBeenCalled(); + }); + + it('should not remount when going back within app', async () => { + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Hitting back button within app does not trigger re-render + await navigate('/app/app1/page2'); + history.goBack(); + await update(); + expect(mounter.mount).toHaveBeenCalledTimes(1); + expect(unmount).not.toHaveBeenCalled(); + }); + + it('should not remount when when changing pages within app using hash history', async () => { + history = createHashHistory(); + update = createRenderer(); + + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Changing hash history does not trigger re-render + await navigate('/app/app1/page2'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + expect(unmount).not.toHaveBeenCalled(); + }); + + it('should unmount when changing between apps', async () => { + const { mounter, unmount } = mounters.get('app1')!; + await navigate('/app/app1/page1'); + expect(mounter.mount).toHaveBeenCalledTimes(1); + + // Navigating to other app triggers unmount + await navigate('/app/app2/page1'); + expect(unmount).toHaveBeenCalledTimes(1); + }); + + it('calls legacy mount handler', async () => { await navigate('/app/legacyApp1'); - expect(redirectTo).toHaveBeenCalledWith('/asdf/app/legacyApp1'); + expect(mounters.get('legacyApp1')!.mounter.mount.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "appBasePath": "/app/legacyApp1", + "element":
, + }, + ] + `); }); it('handles legacy apps with subapps', async () => { await navigate('/app/baseApp'); - expect(redirectTo).toHaveBeenCalledWith('/asdf/app/baseApp'); + expect(mounters.get('baseApp:legacyApp2')!.mounter.mount.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "appBasePath": "/app/baseApp", + "element":
, + }, + ] + `); }); it('displays error page if no app is found', async () => { - await navigate('/app/unknown'); - expect(router.exists(AppNotFound)).toBe(true); + const dom = await navigate('/app/unknown'); + + expect(dom?.exists(AppNotFound)).toBe(true); }); }); diff --git a/src/core/public/application/integration_tests/utils.tsx b/src/core/public/application/integration_tests/utils.tsx new file mode 100644 index 00000000000000..6367d1fa12697e --- /dev/null +++ b/src/core/public/application/integration_tests/utils.tsx @@ -0,0 +1,82 @@ +/* + * 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 React, { ReactElement } from 'react'; +import { mount } from 'enzyme'; + +import { I18nProvider } from '@kbn/i18n/react'; + +import { App, LegacyApp, AppMountParameters } from '../types'; +import { MockedMounter, MockedMounterTuple } from '../test_types'; + +type Dom = ReturnType | null; +type Renderer = () => Dom | Promise; + +export const createRenderer = (element: ReactElement | null): Renderer => { + const dom: Dom = element && mount({element}); + + return () => + new Promise(async resolve => { + if (dom) { + dom.update(); + } + setImmediate(() => resolve(dom)); // flushes any pending promises + }); +}; + +export const createAppMounter = ( + appId: string, + html: string, + appRoute = `/app/${appId}` +): MockedMounterTuple => { + const unmount = jest.fn(); + return [ + appId, + { + mounter: { + appRoute, + appBasePath: appRoute, + mount: jest.fn(async ({ appBasePath: basename, element }: AppMountParameters) => { + Object.assign(element, { + innerHTML: `
\nbasename: ${basename}\nhtml: ${html}\n
`, + }); + unmount.mockImplementation(() => Object.assign(element, { innerHTML: '' })); + return unmount; + }), + }, + unmount, + }, + ]; +}; + +export const createLegacyAppMounter = ( + appId: string, + legacyMount: MockedMounter['mount'] +): MockedMounterTuple => [ + appId, + { + mounter: { + appRoute: `/app/${appId.split(':')[0]}`, + appBasePath: `/app/${appId.split(':')[0]}`, + unmountBeforeMounting: true, + mount: legacyMount, + }, + unmount: jest.fn(), + }, +]; diff --git a/src/core/public/application/test_types.ts b/src/core/public/application/test_types.ts new file mode 100644 index 00000000000000..3d992cb950eb42 --- /dev/null +++ b/src/core/public/application/test_types.ts @@ -0,0 +1,45 @@ +/* + * 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 { App, LegacyApp, Mounter, AppUnmount } from './types'; +import { ApplicationService } from './application_service'; + +/** @internal */ +export type ApplicationServiceContract = PublicMethodsOf; +/** @internal */ +export type EitherApp = App | LegacyApp; +/** @internal */ +export type MockedUnmount = jest.Mocked; +/** @internal */ +export type MockedMounter = jest.Mocked>>; +/** @internal */ +export type MockedMounterTuple = [ + string, + { mounter: MockedMounter; unmount: MockedUnmount } +]; +/** @internal */ +export type MockedMounterMap = Map< + string, + { mounter: MockedMounter; unmount: MockedUnmount } +>; +/** @internal */ +export type MockLifecycle< + T extends keyof ApplicationService, + U = Parameters[0] +> = { [P in keyof U]: jest.Mocked }; diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index fd009066fc6640..c026851af7eb80 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Observable, Subject } from 'rxjs'; +import { Observable } from 'rxjs'; import { Capabilities } from './capabilities'; import { ChromeStart } from '../chrome'; @@ -89,6 +89,13 @@ export interface App extends AppBase { * Takes precedence over chrome service visibility settings. */ chromeless?: boolean; + + /** + * Override the application's routing path from `/app/${id}`. + * Must be unique across registered applications. Should not include the + * base path from HTTP. + */ + appRoute?: string; } /** @internal */ @@ -177,7 +184,8 @@ export interface AppMountParameters { element: HTMLElement; /** - * The base path for configuring the application's router. + * The route path for configuring navigation to the application. + * This string should not include the base path from HTTP. * * @example * @@ -189,6 +197,7 @@ export interface AppMountParameters { * setup({ application }) { * application.register({ * id: 'my-app', + * appRoute: '/my-app', * async mount(params) { * const { renderApp } = await import('./application'); * return renderApp(params); @@ -229,6 +238,23 @@ export interface AppMountParameters { */ export type AppUnmount = () => void; +/** @internal */ +export type AppMounter = (params: AppMountParameters) => Promise; + +/** @internal */ +export type LegacyAppMounter = (params: AppMountParameters) => void; + +/** @internal */ +export type Mounter = SelectivePartial< + { + appRoute: string; + appBasePath: string; + mount: T extends LegacyApp ? LegacyAppMounter : AppMounter; + unmountBeforeMounting: T extends LegacyApp ? true : boolean; + }, + T extends LegacyApp ? never : 'unmountBeforeMounting' +>; + /** @public */ export interface ApplicationSetup { /** @@ -352,6 +378,12 @@ export interface InternalApplicationStart ): void; // Internal APIs - currentAppId$: Subject; + currentAppId$: Observable; getComponent(): JSX.Element | null; } + +/** @internal */ +type SelectivePartial = Partial> & + Required>> extends infer U + ? { [P in keyof U]: U[P] } + : never; diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index 9c2bb30e795032..153582e805fa1b 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -17,95 +17,60 @@ * under the License. */ -import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { Subject } from 'rxjs'; - -import { LegacyApp, AppMount, AppUnmount } from '../types'; -import { HttpStart } from '../../http'; +import React, { + Fragment, + FunctionComponent, + useLayoutEffect, + useRef, + useState, + MutableRefObject, +} from 'react'; + +import { AppUnmount, Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; -interface Props extends RouteComponentProps<{ appId: string }> { - apps: ReadonlyMap; - legacyApps: ReadonlyMap; - basePath: HttpStart['basePath']; - currentAppId$: Subject; - /** - * Only necessary for redirecting to legacy apps - * @deprecated - */ - redirectTo: (path: string) => void; -} - -interface State { - appNotFound: boolean; -} - -export class AppContainer extends React.Component { - private readonly containerDiv = React.createRef(); - private unmountFunc?: AppUnmount; - - state: State = { appNotFound: false }; - - componentDidMount() { - this.mountApp(); - } - - componentWillUnmount() { - this.unmountApp(); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.match.params.appId !== this.props.match.params.appId) { - this.unmountApp(); - this.mountApp(); - } - } - - async mountApp() { - const { apps, legacyApps, match, basePath, currentAppId$, redirectTo } = this.props; - const { appId } = match.params; - - const mount = apps.get(appId); - if (mount) { - this.unmountFunc = await mount({ - appBasePath: basePath.prepend(`/app/${appId}`), - element: this.containerDiv.current!, - }); - currentAppId$.next(appId); - this.setState({ appNotFound: false }); - return; - } - - const legacyApp = findLegacyApp(appId, legacyApps); - if (legacyApp) { - this.unmountApp(); - redirectTo(basePath.prepend(`/app/${appId}`)); - this.setState({ appNotFound: false }); - return; - } - - this.setState({ appNotFound: true }); - } - - async unmountApp() { - if (this.unmountFunc) { - this.unmountFunc(); - this.unmountFunc = undefined; - } - } - - render() { - return ( - - {this.state.appNotFound && } -
- - ); - } +interface Props { + appId: string; + mounter?: Mounter; } -function findLegacyApp(appId: string, apps: ReadonlyMap) { - const matchingApps = [...apps.entries()].filter(([id]) => id.split(':')[0] === appId); - return matchingApps.length ? matchingApps[0][1] : null; -} +export const AppContainer: FunctionComponent = ({ mounter, appId }: Props) => { + const [appNotFound, setAppNotFound] = useState(false); + const elementRef = useRef(null); + const unmountRef: MutableRefObject = useRef(null); + + useLayoutEffect(() => { + const unmount = () => { + if (unmountRef.current) { + unmountRef.current(); + unmountRef.current = null; + } + }; + const mount = async () => { + if (!mounter) { + return setAppNotFound(true); + } + + if (mounter.unmountBeforeMounting) { + unmount(); + } + + unmountRef.current = + (await mounter.mount({ + appBasePath: mounter.appBasePath, + element: elementRef.current!, + })) || null; + setAppNotFound(false); + }; + + mount(); + return unmount; + }, [mounter]); + + return ( + + {appNotFound && } +
+ + ); +}; diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 67701a33dabf41..8db46f97942779 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -17,37 +17,53 @@ * under the License. */ +import React, { FunctionComponent } from 'react'; import { History } from 'history'; -import React from 'react'; -import { Router, Route } from 'react-router-dom'; -import { Subject } from 'rxjs'; +import { Router, Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { LegacyApp, AppMount } from '../types'; +import { Mounter } from '../types'; import { AppContainer } from './app_container'; -import { HttpStart } from '../../http'; interface Props { - apps: ReadonlyMap; - legacyApps: ReadonlyMap; - basePath: HttpStart['basePath']; - currentAppId$: Subject; + mounters: Map; history: History; - /** - * Only necessary for redirecting to legacy apps - * @deprecated - */ - redirectTo?: (path: string) => void; } -export const AppRouter: React.FunctionComponent = ({ - history, - redirectTo = (path: string) => (window.location.href = path), - ...otherProps -}) => ( +interface Params { + appId: string; +} + +export const AppRouter: FunctionComponent = ({ history, mounters }) => ( - } - /> + + {[...mounters].flatMap(([appId, mounter]) => + // Remove /app paths from the routes as they will be handled by the + // "named" route parameter `:appId` below + mounter.appBasePath.startsWith('/app') + ? [] + : [ + } + />, + ] + )} + ) => { + // Find the mounter including legacy mounters with subapps: + const [id, mounter] = mounters.has(appId) + ? [appId, mounters.get(appId)] + : [...mounters].filter(([key]) => key.split(':')[0] === appId)[0] ?? []; + + return ; + }} + /> + ); diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 96567394216869..d9c35b20db03bb 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -211,14 +211,14 @@ describe('start', () => { new FakeApp('beta', true), new FakeApp('gamma', false), ]); - const { availableApps, currentAppId$ } = startDeps.application; + const { availableApps, navigateToApp } = startDeps.application; const { chrome, service } = await start({ startDeps }); const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); - [...availableApps.keys()].forEach(appId => currentAppId$.next(appId)); + [...availableApps.keys()].forEach(appId => navigateToApp(appId)); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` @@ -233,14 +233,14 @@ describe('start', () => { it('changing visibility has no effect on chrome-hiding application', async () => { const startDeps = defaultStartDeps([new FakeApp('alpha', true)]); - const { currentAppId$ } = startDeps.application; + const { navigateToApp } = startDeps.application; const { chrome, service } = await start({ startDeps }); const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); - currentAppId$.next('alpha'); + navigateToApp('alpha'); chrome.setIsVisible(true); service.stop(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 25c00836a4db7a..18c0c9870d72fd 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -127,7 +127,7 @@ export class ChromeService { ) ); this.isVisible$ = combineLatest(this.appHidden$, this.toggleHidden$).pipe( - map(([appHidden, chromeHidden]) => !(appHidden || chromeHidden)), + map(([appHidden, toggleHidden]) => !(appHidden || toggleHidden)), takeUntil(this.stop$) ); } diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 2a9dca96062dc9..485c11aae6508c 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -174,7 +174,7 @@ export class CoreSystem { [this.legacy.legacyId, [...pluginDependencies.keys()]], ]), }); - const application = this.application.setup({ context }); + const application = this.application.setup({ context, http, injectedMetadata }); const core: InternalCoreSetup = { application, @@ -307,6 +307,7 @@ export class CoreSystem { this.uiSettings.stop(); this.chrome.stop(); this.i18n.stop(); + this.application.stop(); this.rootDomElement.textContent = ''; } } diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 6a44000bf617e6..0bde1b68e1876a 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -80,9 +80,6 @@ export interface InjectedMetadataParams { user?: Record; }; }; - apm: { - [key: string]: unknown; - }; }; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index dfbb6b4a6fbf54..f61741571dc1dc 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -18,6 +18,7 @@ import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/type // @public export interface App extends AppBase { + appRoute?: string; chromeless?: boolean; mount: AppMount | AppMountDeprecated; } diff --git a/src/legacy/ui/ui_nav_links/ui_nav_links_mixin.js b/src/core/server/config/config.mock.ts similarity index 61% rename from src/legacy/ui/ui_nav_links/ui_nav_links_mixin.js rename to src/core/server/config/config.mock.ts index e445f5e9126d4b..e098fa142b9d13 100644 --- a/src/legacy/ui/ui_nav_links/ui_nav_links_mixin.js +++ b/src/core/server/config/config.mock.ts @@ -17,18 +17,18 @@ * under the License. */ -import { UiNavLink } from './ui_nav_link'; +import { Config } from './config'; -export function uiNavLinksMixin(kbnServer, server) { - const uiApps = server.getAllUiApps(); +type ConfigMock = jest.Mocked; - const { navLinkSpecs = [] } = kbnServer.uiExports; +const createConfigMock = (): ConfigMock => ({ + has: jest.fn(), + get: jest.fn(), + set: jest.fn(), + getFlattenedPaths: jest.fn(), + toRaw: jest.fn(), +}); - const fromSpecs = navLinkSpecs.map(navLinkSpec => new UiNavLink(navLinkSpec)); - - const fromApps = uiApps.map(app => app.getNavLink()).filter(Boolean); - - const uiNavLinks = fromSpecs.concat(fromApps).sort((a, b) => a.getOrder() - b.getOrder()); - - server.decorate('server', 'getUiNavLinks', () => uiNavLinks.slice(0)); -} +export const configMock = { + create: createConfigMock, +}; diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 1668b409050b74..700ae04f00d47a 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -20,6 +20,7 @@ import { Server } from 'hapi'; import { CspConfig } from '../csp'; import { mockRouter } from './router/router.mock'; +import { configMock } from '../config/config.mock'; import { InternalHttpServiceSetup } from './types'; import { HttpService } from './http_service'; import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; @@ -28,13 +29,14 @@ import { sessionStorageMock } from './cookie_session_storage.mocks'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; +type BasePathMocked = jest.Mocked; export type HttpServiceSetupMock = jest.Mocked & { - basePath: jest.Mocked; + basePath: BasePathMocked; }; -const createBasePathMock = (): jest.Mocked => ({ - serverBasePath: '/mock-server-basepath', - get: jest.fn(), +const createBasePathMock = (serverBasePath = '/mock-server-basepath'): BasePathMocked => ({ + serverBasePath, + get: jest.fn().mockReturnValue(serverBasePath), set: jest.fn(), prepend: jest.fn(), remove: jest.fn(), @@ -44,9 +46,12 @@ const createSetupContractMock = () => { const setupContract: HttpServiceSetupMock = { // we can mock other hapi server methods when we need it server: ({ + name: 'http-server-test', + version: 'kibana', route: jest.fn(), start: jest.fn(), stop: jest.fn(), + config: jest.fn().mockReturnValue(configMock.create()), } as unknown) as jest.MockedClass, createCookieSessionStorageFactory: jest.fn(), registerOnPreAuth: jest.fn(), diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 878f854f2a5174..953fa0738597c0 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -41,6 +41,7 @@ import { ElasticsearchServiceSetup, IScopedClusterClient } from './elasticsearch'; import { HttpServiceSetup } from './http'; +import { IScopedRenderingClient } from './rendering'; import { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId } from './plugins'; import { ContextSetup } from './context'; import { IUiSettingsClient, UiSettingsServiceSetup, UiSettingsServiceStart } from './ui_settings'; @@ -149,6 +150,7 @@ export { SessionCookieValidationResult, SessionStorageFactory, } from './http'; +export { RenderingServiceSetup, IRenderOptions, LegacyRenderOptions } from './rendering'; export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; export { @@ -229,12 +231,21 @@ export { SavedObjectsMigrationVersion, } from './types'; -export { LegacyServiceSetupDeps, LegacyServiceStartDeps } from './legacy'; +export { + LegacyServiceSetupDeps, + LegacyServiceStartDeps, + LegacyServiceDiscoverPlugins, + LegacyConfig, + LegacyUiExports, + LegacyInternals, +} from './legacy'; /** * Plugin specific context passed to a route handler. * * Provides the following clients: + * - {@link IScopedRenderingClient | rendering} - Rendering client + * which uses the data of the incoming request * - {@link SavedObjectsClient | savedObjects.client} - Saved Objects client * which uses the credentials of the incoming request * - {@link ScopedClusterClient | elasticsearch.dataClient} - Elasticsearch @@ -248,6 +259,7 @@ export { LegacyServiceSetupDeps, LegacyServiceStartDeps } from './legacy'; */ export interface RequestHandlerContext { core: { + rendering: IScopedRenderingClient; savedObjects: { client: SavedObjectsClientContract; }; @@ -301,6 +313,7 @@ export { CapabilitiesSetup, CapabilitiesStart, ContextSetup, + IScopedRenderingClient, PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId, diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 52adaaccab4b79..be4d830c55eab7 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -17,15 +17,15 @@ * under the License. */ +import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; +import { ContextSetup } from './context'; import { InternalElasticsearchServiceSetup } from './elasticsearch'; import { InternalHttpServiceSetup } from './http'; -import { InternalUiSettingsServiceSetup, InternalUiSettingsServiceStart } from './ui_settings'; -import { ContextSetup } from './context'; import { - InternalSavedObjectsServiceStart, InternalSavedObjectsServiceSetup, + InternalSavedObjectsServiceStart, } from './saved_objects'; -import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; +import { InternalUiSettingsServiceSetup, InternalUiSettingsServiceStart } from './ui_settings'; import { UuidServiceSetup } from './uuid'; /** @internal */ diff --git a/src/core/server/legacy/config/ensure_valid_configuration.ts b/src/core/server/legacy/config/ensure_valid_configuration.ts index 026683a7b7cb00..a68d3df577a891 100644 --- a/src/core/server/legacy/config/ensure_valid_configuration.ts +++ b/src/core/server/legacy/config/ensure_valid_configuration.ts @@ -19,7 +19,7 @@ import { getUnusedConfigKeys } from './get_unused_config_keys'; import { ConfigService } from '../../config'; -import { LegacyServiceDiscoverPlugins } from '../legacy_service'; +import { LegacyServiceDiscoverPlugins } from '../types'; import { CriticalError } from '../../errors'; export async function ensureValidConfiguration( diff --git a/src/core/server/legacy/config/get_unused_config_keys.test.ts b/src/core/server/legacy/config/get_unused_config_keys.test.ts index bf011fa01a3427..c4452fc6a1209a 100644 --- a/src/core/server/legacy/config/get_unused_config_keys.test.ts +++ b/src/core/server/legacy/config/get_unused_config_keys.test.ts @@ -17,8 +17,7 @@ * under the License. */ -import { LegacyPluginSpec } from '../plugins/find_legacy_plugin_specs'; -import { LegacyConfig } from './types'; +import { LegacyPluginSpec, LegacyConfig, LegacyVars } from '../types'; import { getUnusedConfigKeys } from './get_unused_config_keys'; describe('getUnusedConfigKeys', () => { @@ -26,7 +25,7 @@ describe('getUnusedConfigKeys', () => { jest.resetAllMocks(); }); - const getConfig = (values: Record = {}): LegacyConfig => + const getConfig = (values: LegacyVars = {}): LegacyConfig => ({ get: () => values as any, } as LegacyConfig); diff --git a/src/core/server/legacy/config/get_unused_config_keys.ts b/src/core/server/legacy/config/get_unused_config_keys.ts index 73cc7d8c50474c..e425082ba126dc 100644 --- a/src/core/server/legacy/config/get_unused_config_keys.ts +++ b/src/core/server/legacy/config/get_unused_config_keys.ts @@ -22,8 +22,7 @@ import { difference, get, set } from 'lodash'; import { getTransform } from '../../../../legacy/deprecation/index'; import { unset, getFlattenedObject } from '../../../../legacy/utils'; import { hasConfigPathIntersection } from '../../config'; -import { LegacyPluginSpec } from '../plugins/find_legacy_plugin_specs'; -import { LegacyConfig } from './types'; +import { LegacyPluginSpec, LegacyConfig, LegacyVars } from '../types'; const getFlattenedKeys = (object: object) => Object.keys(getFlattenedObject(object)); @@ -37,7 +36,7 @@ export async function getUnusedConfigKeys({ coreHandledConfigPaths: string[]; pluginSpecs: LegacyPluginSpec[]; disabledPluginSpecs: LegacyPluginSpec[]; - settings: Record; + settings: LegacyVars; legacyConfig: LegacyConfig; }) { // transform deprecated plugin settings diff --git a/src/core/server/legacy/config/index.ts b/src/core/server/legacy/config/index.ts index c3f308fd6d903b..f10e3f22d53c5e 100644 --- a/src/core/server/legacy/config/index.ts +++ b/src/core/server/legacy/config/index.ts @@ -20,9 +20,3 @@ export { ensureValidConfiguration } from './ensure_valid_configuration'; export { LegacyObjectToConfigAdapter } from './legacy_object_to_config_adapter'; export { convertLegacyDeprecationProvider } from './legacy_deprecation_adapters'; -export { - LegacyConfig, - LegacyConfigDeprecation, - LegacyConfigDeprecationFactory, - LegacyConfigDeprecationProvider, -} from './types'; diff --git a/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts b/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts index 144e057c118f70..8651d05064492e 100644 --- a/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts +++ b/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts @@ -17,11 +17,11 @@ * under the License. */ -import { convertLegacyDeprecationProvider } from './legacy_deprecation_adapters'; -import { LegacyConfigDeprecationProvider } from './types'; import { ConfigDeprecation } from '../../config'; import { configDeprecationFactory } from '../../config/deprecation/deprecation_factory'; import { applyDeprecations } from '../../config/deprecation/apply_deprecations'; +import { LegacyConfigDeprecationProvider } from '../types'; +import { convertLegacyDeprecationProvider } from './legacy_deprecation_adapters'; jest.spyOn(configDeprecationFactory, 'unusedFromRoot'); jest.spyOn(configDeprecationFactory, 'renameFromRoot'); diff --git a/src/core/server/legacy/config/legacy_deprecation_adapters.ts b/src/core/server/legacy/config/legacy_deprecation_adapters.ts index b0e3bc37e15107..1e0733969e6628 100644 --- a/src/core/server/legacy/config/legacy_deprecation_adapters.ts +++ b/src/core/server/legacy/config/legacy_deprecation_adapters.ts @@ -18,8 +18,8 @@ */ import { ConfigDeprecation, ConfigDeprecationProvider } from '../../config/deprecation'; -import { LegacyConfigDeprecation, LegacyConfigDeprecationProvider } from './index'; import { configDeprecationFactory } from '../../config/deprecation/deprecation_factory'; +import { LegacyConfigDeprecation, LegacyConfigDeprecationProvider } from '../types'; const convertLegacyDeprecation = ( legacyDeprecation: LegacyConfigDeprecation diff --git a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts index ffcbfda4e024dc..bdcde8262ef981 100644 --- a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts +++ b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts @@ -19,6 +19,7 @@ import { ConfigPath } from '../../config'; import { ObjectToConfigAdapter } from '../../config/object_to_config_adapter'; +import { LegacyVars } from '../types'; /** * Represents logging config supported by the legacy platform. @@ -77,7 +78,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { }; } - private static transformPlugins(configValue: Record) { + private static transformPlugins(configValue: LegacyVars) { // These properties are the only ones we use from the existing `plugins` config node // since `scanDirs` isn't respected by new platform plugin discovery. return { @@ -94,7 +95,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { case 'server': return LegacyObjectToConfigAdapter.transformServer(configValue); case 'plugins': - return LegacyObjectToConfigAdapter.transformPlugins(configValue as Record); + return LegacyObjectToConfigAdapter.transformPlugins(configValue as LegacyVars); default: return configValue; } diff --git a/src/core/server/legacy/config/types.ts b/src/core/server/legacy/config/types.ts deleted file mode 100644 index cac1002d6c2446..00000000000000 --- a/src/core/server/legacy/config/types.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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. - */ - -/** - * New platform representation of the legacy configuration (KibanaConfig) - * - * @internal - */ -export interface LegacyConfig { - get(key?: string): T; - has(key: string): boolean; - set(key: string, value: any): void; - set(config: Record): void; -} - -/** - * Representation of a legacy configuration deprecation factory used for - * legacy plugin deprecations. - * - * @internal - */ -export interface LegacyConfigDeprecationFactory { - rename(oldKey: string, newKey: string): LegacyConfigDeprecation; - unused(unusedKey: string): LegacyConfigDeprecation; -} - -/** - * Representation of a legacy configuration deprecation. - * - * @internal - */ -export type LegacyConfigDeprecation = ( - settings: Record, - log: (msg: string) => void -) => void; - -/** - * Representation of a legacy configuration deprecation provider. - * - * @internal - */ -export type LegacyConfigDeprecationProvider = ( - factory: LegacyConfigDeprecationFactory -) => LegacyConfigDeprecation[] | Promise; diff --git a/src/core/server/legacy/index.ts b/src/core/server/legacy/index.ts index 10686fc521d35e..208e9b11672536 100644 --- a/src/core/server/legacy/index.ts +++ b/src/core/server/legacy/index.ts @@ -18,6 +18,10 @@ */ /** @internal */ -export { LegacyObjectToConfigAdapter, ensureValidConfiguration, LegacyConfig } from './config'; +export { LegacyObjectToConfigAdapter, ensureValidConfiguration } from './config'; /** @internal */ -export { LegacyService, LegacyServiceSetupDeps, LegacyServiceStartDeps } from './legacy_service'; +export { LegacyInternals } from './legacy_internals'; +/** @internal */ +export { LegacyService, ILegacyService } from './legacy_service'; +/** @internal */ +export * from './types'; diff --git a/src/core/server/legacy/legacy_internals.test.ts b/src/core/server/legacy/legacy_internals.test.ts new file mode 100644 index 00000000000000..dcab62627442b2 --- /dev/null +++ b/src/core/server/legacy/legacy_internals.test.ts @@ -0,0 +1,211 @@ +/* + * 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 { Server } from 'hapi'; + +import { configMock } from '../config/config.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { httpServerMock } from '../http/http_server.mocks'; +import { findLegacyPluginSpecsMock } from './legacy_service.test.mocks'; +import { LegacyInternals } from './legacy_internals'; +import { ILegacyInternals, LegacyConfig, LegacyVars, LegacyUiExports } from './types'; + +function varsProvider(vars: LegacyVars, configValue?: any) { + return { + fn: jest.fn().mockReturnValue(vars), + pluginSpec: { + readConfigValue: jest.fn().mockReturnValue(configValue), + }, + }; +} + +describe('LegacyInternals', () => { + describe('getInjectedUiAppVars()', () => { + let uiExports: LegacyUiExports; + let config: LegacyConfig; + let server: Server; + let legacyInternals: ILegacyInternals; + + beforeEach(async () => { + uiExports = findLegacyPluginSpecsMock().uiExports; + config = configMock.create() as any; + server = httpServiceMock.createSetupContract().server; + legacyInternals = new LegacyInternals(uiExports, config, server); + }); + + it('gets with no injectors', async () => { + await expect(legacyInternals.getInjectedUiAppVars('core')).resolves.toMatchInlineSnapshot( + `Object {}` + ); + }); + + it('gets with no matching injectors', async () => { + const injector = jest.fn().mockResolvedValue({ not: 'core' }); + legacyInternals.injectUiAppVars('not-core', injector); + + await expect(legacyInternals.getInjectedUiAppVars('core')).resolves.toMatchInlineSnapshot( + `Object {}` + ); + expect(injector).not.toHaveBeenCalled(); + }); + + it('gets with single matching injector', async () => { + const injector = jest.fn().mockResolvedValue({ is: 'core' }); + legacyInternals.injectUiAppVars('core', injector); + + await expect(legacyInternals.getInjectedUiAppVars('core')).resolves.toMatchInlineSnapshot(` + Object { + "is": "core", + } + `); + expect(injector).toHaveBeenCalled(); + }); + + it('gets with multiple matching injectors', async () => { + const injectors = [ + jest.fn().mockResolvedValue({ is: 'core' }), + jest.fn().mockReturnValue({ sync: 'injector' }), + jest.fn().mockResolvedValue({ is: 'merged-core' }), + ]; + + injectors.forEach(injector => legacyInternals.injectUiAppVars('core', injector)); + + await expect(legacyInternals.getInjectedUiAppVars('core')).resolves.toMatchInlineSnapshot(` + Object { + "is": "merged-core", + "sync": "injector", + } + `); + expect(injectors[0]).toHaveBeenCalled(); + expect(injectors[1]).toHaveBeenCalled(); + expect(injectors[2]).toHaveBeenCalled(); + }); + }); + + describe('getVars()', () => { + let uiExports: LegacyUiExports; + let config: LegacyConfig; + let server: Server; + let legacyInternals: LegacyInternals; + + beforeEach(async () => { + uiExports = findLegacyPluginSpecsMock().uiExports; + config = configMock.create() as any; + server = httpServiceMock.createSetupContract().server; + legacyInternals = new LegacyInternals(uiExports, config, server); + }); + + it('gets: no default injectors, no injected vars replacers, no ui app injectors, no inject arg', async () => { + const vars = await legacyInternals.getVars('core', httpServerMock.createRawRequest()); + + expect(vars).toMatchInlineSnapshot(`Object {}`); + }); + + it('gets: with default injectors, no injected vars replacers, no ui app injectors, no inject arg', async () => { + uiExports.defaultInjectedVarProviders = [ + varsProvider({ alpha: 'alpha' }), + varsProvider({ gamma: 'gamma' }), + varsProvider({ alpha: 'beta' }), + ]; + + const vars = await legacyInternals.getVars('core', httpServerMock.createRawRequest()); + + expect(vars).toMatchInlineSnapshot(` + Object { + "alpha": "beta", + "gamma": "gamma", + } + `); + }); + + it('gets: no default injectors, with injected vars replacers, with ui app injectors, no inject arg', async () => { + uiExports.injectedVarsReplacers = [ + jest.fn(async vars => ({ ...vars, added: 'key' })), + jest.fn(vars => vars), + jest.fn(vars => ({ replaced: 'all' })), + jest.fn(async vars => ({ ...vars, added: 'last-key' })), + ]; + + const request = httpServerMock.createRawRequest(); + const vars = await legacyInternals.getVars('core', request); + + expect(vars).toMatchInlineSnapshot(` + Object { + "added": "last-key", + "replaced": "all", + } + `); + }); + + it('gets: no default injectors, no injected vars replacers, with ui app injectors, no inject arg', async () => { + legacyInternals.injectUiAppVars('core', async () => ({ is: 'core' })); + legacyInternals.injectUiAppVars('core', () => ({ sync: 'injector' })); + legacyInternals.injectUiAppVars('core', async () => ({ is: 'merged-core' })); + + const vars = await legacyInternals.getVars('core', httpServerMock.createRawRequest()); + + expect(vars).toMatchInlineSnapshot(` + Object { + "is": "merged-core", + "sync": "injector", + } + `); + }); + + it('gets: no default injectors, no injected vars replacers, no ui app injectors, with inject arg', async () => { + const vars = await legacyInternals.getVars('core', httpServerMock.createRawRequest(), { + injected: 'arg', + }); + + expect(vars).toMatchInlineSnapshot(` + Object { + "injected": "arg", + } + `); + }); + + it('gets: with default injectors, with injected vars replacers, with ui app injectors, with inject arg', async () => { + uiExports.defaultInjectedVarProviders = [ + varsProvider({ alpha: 'alpha' }), + varsProvider({ gamma: 'gamma' }), + varsProvider({ alpha: 'beta' }), + ]; + uiExports.injectedVarsReplacers = [jest.fn(async vars => ({ ...vars, gamma: 'delta' }))]; + + legacyInternals.injectUiAppVars('core', async () => ({ is: 'core' })); + legacyInternals.injectUiAppVars('core', () => ({ sync: 'injector' })); + legacyInternals.injectUiAppVars('core', async () => ({ is: 'merged-core' })); + + const vars = await legacyInternals.getVars('core', httpServerMock.createRawRequest(), { + injected: 'arg', + sync: 'arg', + }); + + expect(vars).toMatchInlineSnapshot(` + Object { + "alpha": "beta", + "gamma": "delta", + "injected": "arg", + "is": "merged-core", + "sync": "arg", + } + `); + }); + }); +}); diff --git a/src/core/server/legacy/legacy_internals.ts b/src/core/server/legacy/legacy_internals.ts new file mode 100644 index 00000000000000..3bf54e5f75dce5 --- /dev/null +++ b/src/core/server/legacy/legacy_internals.ts @@ -0,0 +1,87 @@ +/* + * 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 { Server } from 'hapi'; + +import { LegacyRequest } from '../http'; +import { mergeVars } from './merge_vars'; +import { ILegacyInternals, LegacyVars, VarsInjector, LegacyConfig, LegacyUiExports } from './types'; + +/** + * @internal + * @deprecated + */ +export class LegacyInternals implements ILegacyInternals { + private readonly injectors = new Map>(); + private cachedDefaultVars?: LegacyVars; + + constructor( + private readonly uiExports: LegacyUiExports, + private readonly config: LegacyConfig, + private readonly server: Server + ) {} + + private get defaultVars(): LegacyVars { + if (this.cachedDefaultVars) { + return this.cachedDefaultVars; + } + + const { defaultInjectedVarProviders = [] } = this.uiExports; + + return (this.cachedDefaultVars = defaultInjectedVarProviders.reduce( + (vars, { fn, pluginSpec }) => + mergeVars(vars, fn(this.server, pluginSpec.readConfigValue(this.config, []))), + {} + )); + } + + private replaceVars(vars: LegacyVars, request: LegacyRequest) { + const { injectedVarsReplacers = [] } = this.uiExports; + + return injectedVarsReplacers.reduce( + async (injected, replacer) => replacer(await injected, request, this.server), + Promise.resolve(vars) + ); + } + + public injectUiAppVars(id: string, injector: VarsInjector) { + if (!this.injectors.has(id)) { + this.injectors.set(id, new Set()); + } + + this.injectors.get(id)!.add(injector); + } + + public getInjectedUiAppVars(id: string) { + return [...(this.injectors.get(id) || [])].reduce( + async (promise, injector) => ({ + ...(await promise), + ...(await injector()), + }), + Promise.resolve({}) + ); + } + + public async getVars(id: string, request: LegacyRequest, injected: LegacyVars = {}) { + return this.replaceVars( + mergeVars(this.defaultVars, await this.getInjectedUiAppVars(id), injected), + request + ); + } +} diff --git a/src/core/server/legacy/legacy_service.mock.ts b/src/core/server/legacy/legacy_service.mock.ts index ac0319cdf4eb5e..495141cdcb58de 100644 --- a/src/core/server/legacy/legacy_service.mock.ts +++ b/src/core/server/legacy/legacy_service.mock.ts @@ -17,23 +17,33 @@ * under the License. */ -import { LegacyServiceDiscoverPlugins } from './legacy_service'; +import { LegacyService } from './legacy_service'; +import { LegacyServiceDiscoverPlugins, LegacyServiceSetupDeps } from './types'; -const createDiscoverMock = () => { - const setupContract: DeeplyMockedKeys = { - pluginSpecs: [], - disabledPluginSpecs: [], - uiExports: {} as any, - settings: {}, - pluginExtendedConfig: { - get: jest.fn(), - has: jest.fn(), - set: jest.fn(), - } as any, - }; - return setupContract; -}; +type LegacyServiceMock = jest.Mocked & { legacyId: symbol }>; + +const createDiscoverPluginsMock = (): LegacyServiceDiscoverPlugins => ({ + pluginSpecs: [], + uiExports: {} as any, + navLinks: [], + pluginExtendedConfig: { + get: jest.fn(), + has: jest.fn(), + set: jest.fn(), + }, + disabledPluginSpecs: [], + settings: {}, +}); +const createLegacyServiceMock = (): LegacyServiceMock => ({ + legacyId: Symbol(), + discoverPlugins: jest.fn().mockResolvedValue(createDiscoverPluginsMock()), + setup: jest.fn(), + start: jest.fn(), + stop: jest.fn(), +}); export const legacyServiceMock = { - createDiscover: createDiscoverMock, + create: createLegacyServiceMock, + createSetupContract: (deps: LegacyServiceSetupDeps) => createLegacyServiceMock().setup(deps), + createDiscoverPlugins: createDiscoverPluginsMock, }; diff --git a/src/core/server/legacy/legacy_service.test.mocks.ts b/src/core/server/legacy/legacy_service.test.mocks.ts index e8d4a0ed0bd4d6..451a75ced7ae29 100644 --- a/src/core/server/legacy/legacy_service.test.mocks.ts +++ b/src/core/server/legacy/legacy_service.test.mocks.ts @@ -17,18 +17,19 @@ * under the License. */ -export const findLegacyPluginSpecsMock = jest - .fn() - .mockImplementation((settings: Record) => ({ - pluginSpecs: [], - pluginExtendedConfig: { - has: jest.fn(), - get: jest.fn(() => settings), - set: jest.fn(), - }, - disabledPluginSpecs: [], - uiExports: [], - })); +import { LegacyVars } from './types'; + +export const findLegacyPluginSpecsMock = jest.fn().mockImplementation((settings: LegacyVars) => ({ + pluginSpecs: [], + pluginExtendedConfig: { + has: jest.fn(), + get: jest.fn().mockReturnValue(settings), + set: jest.fn(), + }, + disabledPluginSpecs: [], + uiExports: {}, + navLinks: [], +})); jest.doMock('./plugins/find_legacy_plugin_specs.ts', () => ({ findLegacyPluginSpecs: findLegacyPluginSpecsMock, })); diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index c652bb1c948878..608392e4943f91 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -25,7 +25,7 @@ jest.mock('./config/legacy_deprecation_adapters', () => ({ import { findLegacyPluginSpecsMock } from './legacy_service.test.mocks'; import { BehaviorSubject, throwError } from 'rxjs'; -import { LegacyService, LegacyServiceSetupDeps, LegacyServiceStartDeps } from '.'; + // @ts-ignore: implicit any for JS file import { ClusterManager as MockClusterManager } from '../../../cli/cluster/cluster_manager'; import KbnServer from '../../../legacy/server/kbn_server'; @@ -33,7 +33,6 @@ import { Config, Env, ObjectToConfigAdapter } from '../config'; import { getEnvOptions } from '../config/__mocks__/env'; import { BasePathProxyServer } from '../http'; import { DiscoveredPlugin } from '../plugins'; -import { findLegacyPluginSpecs } from './plugins/find_legacy_plugin_specs'; import { configServiceMock } from '../config/config_service.mock'; import { loggingServiceMock } from '../logging/logging_service.mock'; @@ -42,7 +41,11 @@ import { httpServiceMock } from '../http/http_service.mock'; import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; import { capabilitiesServiceMock } from '../capabilities/capabilities_service.mock'; +import { setupMock as renderingServiceMock } from '../rendering/__mocks__/rendering_service'; import { uuidServiceMock } from '../uuid/uuid_service.mock'; +import { findLegacyPluginSpecs } from './plugins'; +import { LegacyVars, LegacyServiceSetupDeps, LegacyServiceStartDeps } from './types'; +import { LegacyService } from './legacy_service'; const MockKbnServer: jest.Mock = KbnServer as any; @@ -89,6 +92,7 @@ beforeEach(() => { browserConfigs: new Map(), }, }, + rendering: renderingServiceMock, uuid: uuidSetup, }, plugins: { 'plugin-id': 'plugin-value' }, @@ -138,7 +142,7 @@ describe('once LegacyService is set up with connection info', () => { { path: { autoListen: true }, server: { autoListen: true } }, // Because of the mock, path also gets the value expect.objectContaining({ get: expect.any(Function) }), expect.any(Object), - { disabledPluginSpecs: [], pluginSpecs: [], uiExports: [] } + { disabledPluginSpecs: [], pluginSpecs: [], uiExports: {}, navLinks: [] } ); expect(MockKbnServer.mock.calls[0][1].get()).toEqual({ path: { autoListen: true }, @@ -168,7 +172,7 @@ describe('once LegacyService is set up with connection info', () => { { path: { autoListen: false }, server: { autoListen: true } }, expect.objectContaining({ get: expect.any(Function) }), expect.any(Object), - { disabledPluginSpecs: [], pluginSpecs: [], uiExports: [] } + { disabledPluginSpecs: [], pluginSpecs: [], uiExports: {}, navLinks: [] } ); expect(MockKbnServer.mock.calls[0][1].get()).toEqual({ path: { autoListen: false }, @@ -309,7 +313,7 @@ describe('once LegacyService is set up without connection info', () => { { path: {}, server: { autoListen: true } }, expect.objectContaining({ get: expect.any(Function) }), expect.any(Object), - { disabledPluginSpecs: [], pluginSpecs: [], uiExports: [] } + { disabledPluginSpecs: [], pluginSpecs: [], uiExports: {}, navLinks: [] } ); expect(MockKbnServer.mock.calls[0][1].get()).toEqual({ path: {}, @@ -395,16 +399,18 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { }); }); -test('Cannot start without setup phase', async () => { - const legacyService = new LegacyService({ - coreId, - env, - logger, - configService: configService as any, +describe('start', () => { + test('Cannot start without setup phase', async () => { + const legacyService = new LegacyService({ + coreId, + env, + logger, + configService: configService as any, + }); + await expect(legacyService.start(startDeps)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Legacy service is not setup yet."` + ); }); - await expect(legacyService.start(startDeps)).rejects.toThrowErrorMatchingInlineSnapshot( - `"Legacy service is not setup yet."` - ); }); describe('#discoverPlugins()', () => { @@ -438,7 +444,8 @@ describe('#discoverPlugins()', () => { ], pluginExtendedConfig: settings, disabledPluginSpecs: [], - uiExports: [], + uiExports: {}, + navLinks: [], }) as any ); @@ -469,15 +476,16 @@ test('Sets the server.uuid property on the legacy configuration', async () => { const configSetMock = jest.fn(); - findLegacyPluginSpecsMock.mockImplementation((settings: Record) => ({ + findLegacyPluginSpecsMock.mockImplementation((settings: LegacyVars) => ({ pluginSpecs: [], pluginExtendedConfig: { has: jest.fn(), - get: jest.fn(() => settings), + get: jest.fn().mockReturnValue(settings), set: configSetMock, }, disabledPluginSpecs: [], - uiExports: [], + uiExports: {}, + navLinks: [], })); await legacyService.discoverPlugins(); diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 2e8a467eff995f..2ed87f4c6d4886 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -19,24 +19,30 @@ import { combineLatest, ConnectableObservable, EMPTY, Observable, Subscription } from 'rxjs'; import { first, map, publishReplay, tap } from 'rxjs/operators'; + import { CoreService } from '../../types'; -import { CoreSetup, CoreStart } from '../'; -import { InternalCoreSetup, InternalCoreStart } from '../internal_types'; -import { SavedObjectsLegacyUiExports } from '../types'; import { Config, ConfigDeprecationProvider } from '../config'; import { CoreContext } from '../core_context'; import { CspConfigType, config as cspConfig } from '../csp'; import { DevConfig, DevConfigType, config as devConfig } from '../dev'; import { BasePathProxyServer, HttpConfig, HttpConfigType, config as httpConfig } from '../http'; import { Logger } from '../logging'; -import { PluginsServiceSetup, PluginsServiceStart } from '../plugins'; -import { findLegacyPluginSpecs } from './plugins'; -import { LegacyPluginSpec } from './plugins/find_legacy_plugin_specs'; import { PathConfigType } from '../path'; -import { LegacyConfig, convertLegacyDeprecationProvider } from './config'; +import { findLegacyPluginSpecs } from './plugins'; +import { convertLegacyDeprecationProvider } from './config'; +import { + LegacyServiceSetupDeps, + LegacyServiceStartDeps, + LegacyPlugins, + LegacyServiceDiscoverPlugins, + LegacyConfig, + LegacyVars, +} from './types'; +import { LegacyInternals } from './legacy_internals'; +import { CoreSetup, CoreStart } from '..'; interface LegacyKbnServer { - applyLoggingConfiguration: (settings: Readonly>) => void; + applyLoggingConfiguration: (settings: Readonly) => void; listen: () => Promise; ready: () => Promise; close: () => Promise; @@ -53,43 +59,14 @@ function getLegacyRawConfig(config: Config, pathConfig: PathConfigType) { return { ...rawConfig, - path: pathConfig, // We rely heavily in the default value of 'path.data' in the legacy world and, since it has been moved to NP, it won't show up in RawConfig - }; -} - -/** - * @public - * @deprecated - */ -export interface LegacyServiceSetupDeps { - core: InternalCoreSetup & { - plugins: PluginsServiceSetup; - }; - plugins: Record; -} - -/** - * @public - * @deprecated - */ -export interface LegacyServiceStartDeps { - core: InternalCoreStart & { - plugins: PluginsServiceStart; + // We rely heavily in the default value of 'path.data' in the legacy world and, + // since it has been moved to NP, it won't show up in RawConfig. + path: pathConfig, }; - plugins: Record; } /** @internal */ -export interface LegacyServiceDiscoverPlugins { - pluginSpecs: LegacyPluginSpec[]; - disabledPluginSpecs: LegacyPluginSpec[]; - uiExports: SavedObjectsLegacyUiExports; - pluginExtendedConfig: LegacyConfig; - settings: Record; -} - -/** @internal */ -export type ILegacyService = Pick; +export type ILegacyService = PublicMethodsOf; /** @internal */ export class LegacyService implements CoreService { @@ -101,16 +78,10 @@ export class LegacyService implements CoreService { private kbnServer?: LegacyKbnServer; private configSubscription?: Subscription; private setupDeps?: LegacyServiceSetupDeps; - private update$: ConnectableObservable<[Config, PathConfigType]> | undefined; - private legacyRawConfig: LegacyConfig | undefined; - private legacyPlugins: - | { - pluginSpecs: LegacyPluginSpec[]; - disabledPluginSpecs: LegacyPluginSpec[]; - uiExports: SavedObjectsLegacyUiExports; - } - | undefined; - private settings: Record | undefined; + private update$?: ConnectableObservable<[Config, PathConfigType]>; + private legacyRawConfig?: LegacyConfig; + private legacyPlugins?: LegacyPlugins; + private settings?: LegacyVars; constructor(private readonly coreContext: CoreContext) { const { logger, configService, env } = coreContext; @@ -153,12 +124,14 @@ export class LegacyService implements CoreService { pluginExtendedConfig, disabledPluginSpecs, uiExports, + navLinks, } = await findLegacyPluginSpecs(this.settings, this.coreContext.logger); this.legacyPlugins = { pluginSpecs, disabledPluginSpecs, uiExports, + navLinks, }; const deprecationProviders = await pluginSpecs @@ -188,6 +161,7 @@ export class LegacyService implements CoreService { pluginSpecs, disabledPluginSpecs, uiExports, + navLinks, pluginExtendedConfig, settings: this.settings, }; @@ -195,35 +169,37 @@ export class LegacyService implements CoreService { public async setup(setupDeps: LegacyServiceSetupDeps) { this.log.debug('setting up legacy service'); - if (!this.legacyRawConfig || !this.legacyPlugins || !this.settings) { + + if (!this.legacyPlugins) { throw new Error( 'Legacy service has not discovered legacy plugins yet. Ensure LegacyService.discoverPlugins() is called before LegacyService.setup()' ); } - // propagate the instance uuid to the legacy config, as it was the legacy way to access it. - this.legacyRawConfig.set('server.uuid', setupDeps.core.uuid.getInstanceUuid()); + // propagate the instance uuid to the legacy config, as it was the legacy way to access it. + this.legacyRawConfig!.set('server.uuid', setupDeps.core.uuid.getInstanceUuid()); this.setupDeps = setupDeps; } public async start(startDeps: LegacyServiceStartDeps) { const { setupDeps } = this; - if (!setupDeps || !this.legacyRawConfig || !this.legacyPlugins || !this.settings) { + + if (!setupDeps || !this.legacyPlugins) { throw new Error('Legacy service is not setup yet.'); } + this.log.debug('starting legacy service'); // Receive initial config and create kbnServer/ClusterManager. - if (this.coreContext.env.isDevClusterMaster) { - await this.createClusterManager(this.legacyRawConfig); + await this.createClusterManager(this.legacyRawConfig!); } else { this.kbnServer = await this.createKbnServer( - this.settings, - this.legacyRawConfig, + this.settings!, + this.legacyRawConfig!, setupDeps, startDeps, - this.legacyPlugins + this.legacyPlugins! ); } } @@ -263,15 +239,11 @@ export class LegacyService implements CoreService { } private async createKbnServer( - settings: Record, + settings: LegacyVars, config: LegacyConfig, setupDeps: LegacyServiceSetupDeps, startDeps: LegacyServiceStartDeps, - legacyPlugins: { - pluginSpecs: LegacyPluginSpec[]; - disabledPluginSpecs: LegacyPluginSpec[]; - uiExports: SavedObjectsLegacyUiExports; - } + legacyPlugins: LegacyPlugins ) { const coreSetup: CoreSetup = { capabilities: setupDeps.core.capabilities, @@ -338,8 +310,10 @@ export class LegacyService implements CoreService { kibanaMigrator: startDeps.core.savedObjects.migrator, uiPlugins: setupDeps.core.plugins.uiPlugins, elasticsearch: setupDeps.core.elasticsearch, + rendering: setupDeps.core.rendering, uiSettings: setupDeps.core.uiSettings, savedObjectsClientProvider: startDeps.core.savedObjects.clientProvider, + legacy: new LegacyInternals(legacyPlugins.uiExports, config, setupDeps.core.http.server), }, logger: this.coreContext.logger, }, diff --git a/src/core/server/legacy/logging/appenders/legacy_appender.ts b/src/core/server/legacy/logging/appenders/legacy_appender.ts index 011dfae8a5cefc..6d82d929e7daa1 100644 --- a/src/core/server/legacy/logging/appenders/legacy_appender.ts +++ b/src/core/server/legacy/logging/appenders/legacy_appender.ts @@ -21,6 +21,7 @@ import { schema } from '@kbn/config-schema'; import { DisposableAppender } from '../../../logging/appenders/appenders'; import { LogRecord } from '../../../logging/log_record'; import { LegacyLoggingServer } from '../legacy_logging_server'; +import { LegacyVars } from '../../types'; /** * Simple appender that just forwards `LogRecord` to the legacy KbnServer log. @@ -34,7 +35,7 @@ export class LegacyAppender implements DisposableAppender { private readonly loggingServer: LegacyLoggingServer; - constructor(legacyLoggingConfig: Readonly>) { + constructor(legacyLoggingConfig: Readonly) { this.loggingServer = new LegacyLoggingServer(legacyLoggingConfig); } diff --git a/src/core/server/legacy/logging/legacy_logging_server.ts b/src/core/server/legacy/logging/legacy_logging_server.ts index 57706bcac2232f..85a8686b4eded6 100644 --- a/src/core/server/legacy/logging/legacy_logging_server.ts +++ b/src/core/server/legacy/logging/legacy_logging_server.ts @@ -25,9 +25,10 @@ import { Config } from '../../../../legacy/server/config'; import { setupLogging } from '../../../../legacy/server/logging'; import { LogLevel } from '../../logging/log_level'; import { LogRecord } from '../../logging/log_record'; +import { LegacyVars } from '../../types'; export const metadataSymbol = Symbol('log message with metadata'); -export function attachMetaData(message: string, metadata: Record = {}) { +export function attachMetaData(message: string, metadata: LegacyVars = {}) { return { [metadataSymbol]: { message, @@ -50,7 +51,7 @@ interface PluginRegisterParams { options: PluginRegisterParams['options'] ) => Promise; }; - options: Record; + options: LegacyVars; } /** @@ -84,7 +85,7 @@ export class LegacyLoggingServer { private onPostStopCallback?: () => void; - constructor(legacyLoggingConfig: Readonly>) { + constructor(legacyLoggingConfig: Readonly) { // We set `ops.interval` to max allowed number and `ops` filter to value // that doesn't exist to avoid logging of ops at all, if turned on it will be // logged by the "legacy" Kibana. diff --git a/src/legacy/ui/ui_render/lib/merge_variables.test.ts b/src/core/server/legacy/merge_vars.test.ts similarity index 58% rename from src/legacy/ui/ui_render/lib/merge_variables.test.ts rename to src/core/server/legacy/merge_vars.test.ts index 4d69216bc0bfda..d977ee292d039d 100644 --- a/src/legacy/ui/ui_render/lib/merge_variables.test.ts +++ b/src/core/server/legacy/merge_vars.test.ts @@ -17,29 +17,26 @@ * under the License. */ -import { mergeVariables } from './merge_variables'; +import { mergeVars } from './merge_vars'; -describe('mergeVariables', () => { +describe('mergeVars', () => { it('merges two objects together', () => { - const someVariables = { - name: 'value', - canFoo: true, - nested: { - anotherVariable: 'ok', - }, - }; - - const otherVariables = { + const first = { otherName: 'value', otherCanFoo: true, otherNested: { otherAnotherVariable: 'ok', }, }; + const second = { + name: 'value', + canFoo: true, + nested: { + anotherVariable: 'ok', + }, + }; - const result = mergeVariables(someVariables, otherVariables); - - expect(result).toEqual({ + expect(mergeVars(first, second)).toEqual({ name: 'value', canFoo: true, nested: { @@ -54,86 +51,76 @@ describe('mergeVariables', () => { }); it('does not mutate the source objects', () => { - const original = { - var1: 'original', + const first = { + var1: 'first', }; - - const set1 = { - var1: 'value1', - var2: 'value1', + const second = { + var1: 'second', + var2: 'second', }; - - const set2 = { - var1: 'value2', - var2: 'value2', - var3: 'value2', + const third = { + var1: 'third', + var2: 'third', + var3: 'third', }; - - const set3 = { - var1: 'value3', - var2: 'value3', - var3: 'value3', - var4: 'value3', + const fourth = { + var1: 'fourth', + var2: 'fourth', + var3: 'fourth', + var4: 'fourth', }; - mergeVariables(original, set1, set2, set3); + mergeVars(first, second, third, fourth); - expect(original).toEqual({ var1: 'original' }); - expect(set1).toEqual({ var1: 'value1', var2: 'value1' }); - expect(set2).toEqual({ var1: 'value2', var2: 'value2', var3: 'value2' }); - expect(set3).toEqual({ var1: 'value3', var2: 'value3', var3: 'value3', var4: 'value3' }); + expect(first).toEqual({ var1: 'first' }); + expect(second).toEqual({ var1: 'second', var2: 'second' }); + expect(third).toEqual({ var1: 'third', var2: 'third', var3: 'third' }); + expect(fourth).toEqual({ var1: 'fourth', var2: 'fourth', var3: 'fourth', var4: 'fourth' }); }); - it('merges multiple objects together, preferring the leftmost values', () => { - const original = { - var1: 'original', + it('merges multiple objects together with precedence increasing from left-to-right', () => { + const first = { + var1: 'first', + var2: 'first', + var3: 'first', + var4: 'first', }; - - const set1 = { - var1: 'value1', - var2: 'value1', + const second = { + var1: 'second', + var2: 'second', + var3: 'second', }; - - const set2 = { - var1: 'value2', - var2: 'value2', - var3: 'value2', + const third = { + var1: 'third', + var2: 'third', }; - - const set3 = { - var1: 'value3', - var2: 'value3', - var3: 'value3', - var4: 'value3', + const fourth = { + var1: 'fourth', }; - const result = mergeVariables(original, set1, set2, set3); - - expect(result).toEqual({ - var1: 'original', - var2: 'value1', - var3: 'value2', - var4: 'value3', + expect(mergeVars(first, second, third, fourth)).toEqual({ + var1: 'fourth', + var2: 'third', + var3: 'second', + var4: 'first', }); }); - it('retains the original variable value if a duplicate entry is found', () => { - const someVariables = { - name: 'value', - canFoo: true, + it('overwrites the original variable value if a duplicate entry is found', () => { + const first = { nested: { - anotherVariable: 'ok', + otherAnotherVariable: 'ok', }, }; - - const otherVariables = { + const second = { + name: 'value', + canFoo: true, nested: { - otherAnotherVariable: 'ok', + anotherVariable: 'ok', }, }; - const result = mergeVariables(someVariables, otherVariables); - expect(result).toEqual({ + expect(mergeVars(first, second)).toEqual({ name: 'value', canFoo: true, nested: { @@ -143,55 +130,61 @@ describe('mergeVariables', () => { }); it('combines entries within "uiCapabilities"', () => { - const someVariables = { - name: 'value', - canFoo: true, + const first = { uiCapabilities: { firstCapability: 'ok', + sharedCapability: 'shared', }, }; - - const otherVariables = { + const second = { + name: 'value', + canFoo: true, uiCapabilities: { secondCapability: 'ok', }, }; + const third = { + name: 'value', + canFoo: true, + uiCapabilities: { + thirdCapability: 'ok', + sharedCapability: 'blocked', + }, + }; - const result = mergeVariables(someVariables, otherVariables); - - expect(result).toEqual({ + expect(mergeVars(first, second, third)).toEqual({ name: 'value', canFoo: true, uiCapabilities: { firstCapability: 'ok', secondCapability: 'ok', + thirdCapability: 'ok', + sharedCapability: 'blocked', }, }); }); it('does not deeply combine entries within "uiCapabilities"', () => { - const someVariables = { - name: 'value', - canFoo: true, + const first = { uiCapabilities: { firstCapability: 'ok', nestedCapability: { - nestedProp: 'nestedValue', + otherNestedProp: 'otherNestedValue', }, }, }; - - const otherVariables = { + const second = { + name: 'value', + canFoo: true, uiCapabilities: { secondCapability: 'ok', nestedCapability: { - otherNestedProp: 'otherNestedValue', + nestedProp: 'nestedValue', }, }, }; - const result = mergeVariables(someVariables, otherVariables); - expect(result).toEqual({ + expect(mergeVars(first, second)).toEqual({ name: 'value', canFoo: true, uiCapabilities: { diff --git a/src/legacy/ui/ui_render/lib/merge_variables.ts b/src/core/server/legacy/merge_vars.ts similarity index 65% rename from src/legacy/ui/ui_render/lib/merge_variables.ts rename to src/core/server/legacy/merge_vars.ts index 0f65c7825bdba3..a1d43af2f861de 100644 --- a/src/legacy/ui/ui_render/lib/merge_variables.ts +++ b/src/core/server/legacy/merge_vars.ts @@ -17,23 +17,18 @@ * under the License. */ -const ELIGIBLE_FLAT_MERGE_KEYS = ['uiCapabilities']; - -export function mergeVariables(...sources: Array>) { - const result: Record = {}; +import { LegacyVars } from './types'; - for (const source of sources) { - Object.entries(source).forEach(([key, value]) => { - if (ELIGIBLE_FLAT_MERGE_KEYS.includes(key)) { - result[key] = { - ...value, - ...result[key], - }; - } else if (!result.hasOwnProperty(key)) { - result[key] = value; - } - }); - } +const ELIGIBLE_FLAT_MERGE_KEYS = ['uiCapabilities']; - return result; +export function mergeVars(...sources: LegacyVars[]): LegacyVars { + return Object.assign( + {}, + ...sources, + ...ELIGIBLE_FLAT_MERGE_KEYS.flatMap(key => + sources.some(source => key in source) + ? [{ [key]: Object.assign({}, ...sources.map(source => source[key] || {})) }] + : [] + ) + ); } diff --git a/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts b/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts index 0a49154801e563..d2e7a39236d0a8 100644 --- a/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts +++ b/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts @@ -19,25 +19,77 @@ import { Observable, merge, forkJoin } from 'rxjs'; import { toArray, tap, distinct, map } from 'rxjs/operators'; + import { findPluginSpecs, defaultConfig, // @ts-ignore } from '../../../../legacy/plugin_discovery/find_plugin_specs.js'; -import { LoggerFactory } from '../../logging'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { collectUiExports as collectLegacyUiExports } from '../../../../legacy/ui/ui_exports/collect_ui_exports'; -import { LegacyConfig, LegacyConfigDeprecationProvider } from '../config'; -export interface LegacyPluginPack { - getPath(): string; +import { LoggerFactory } from '../../logging'; +import { + LegacyUiExports, + LegacyNavLink, + LegacyPluginSpec, + LegacyPluginPack, + LegacyConfig, +} from '../types'; + +const REMOVE_FROM_ARRAY: LegacyNavLink[] = []; + +function getUiAppsNavLinks({ uiAppSpecs = [] }: LegacyUiExports, pluginSpecs: LegacyPluginSpec[]) { + return uiAppSpecs.flatMap(spec => { + if (!spec) { + return REMOVE_FROM_ARRAY; + } + + const id = spec.pluginId || spec.id; + + if (!id) { + throw new Error('Every app must specify an id'); + } + + if (spec.pluginId && !pluginSpecs.some(plugin => plugin.getId() === spec.pluginId)) { + throw new Error(`Unknown plugin id "${spec.pluginId}"`); + } + + const listed = typeof spec.listed === 'boolean' ? spec.listed : true; + + if (spec.hidden || !listed) { + return REMOVE_FROM_ARRAY; + } + + return { + id, + title: spec.title, + order: typeof spec.order === 'number' ? spec.order : 0, + icon: spec.icon, + euiIconType: spec.euiIconType, + url: spec.url || `/app/${id}`, + linkToLastSubUrl: spec.linkToLastSubUrl, + }; + }); } -export interface LegacyPluginSpec { - getId: () => unknown; - getExpectedKibanaVersion: () => string; - getConfigPrefix: () => string; - getDeprecationsProvider: () => LegacyConfigDeprecationProvider | undefined; +function getNavLinks(uiExports: LegacyUiExports, pluginSpecs: LegacyPluginSpec[]) { + return (uiExports.navLinkSpecs || []) + .map(spec => ({ + id: spec.id, + title: spec.title, + order: typeof spec.order === 'number' ? spec.order : 0, + url: spec.url, + subUrlBase: spec.subUrlBase || spec.url, + icon: spec.icon, + euiIconType: spec.euiIconType, + linkToLastSub: 'linkToLastSubUrl' in spec ? spec.linkToLastSubUrl : false, + hidden: 'hidden' in spec ? spec.hidden : false, + disabled: 'disabled' in spec ? spec.disabled : false, + tooltip: spec.tooltip || '', + })) + .concat(getUiAppsNavLinks(uiExports, pluginSpecs)) + .sort((a, b) => a.order - b.order); } export async function findLegacyPluginSpecs(settings: unknown, loggerFactory: LoggerFactory) { @@ -128,11 +180,14 @@ export async function findLegacyPluginSpecs(settings: unknown, loggerFactory: Lo spec$.pipe(toArray()), log$.pipe(toArray()) ).toPromise(); + const uiExports = collectLegacyUiExports(pluginSpecs); + const navLinks = getNavLinks(uiExports, pluginSpecs); return { disabledPluginSpecs, pluginSpecs, pluginExtendedConfig: configToMutate, - uiExports: collectLegacyUiExports(pluginSpecs), + uiExports, + navLinks, }; } diff --git a/src/core/server/legacy/plugins/index.ts b/src/core/server/legacy/plugins/index.ts index 7c69546f0c4de3..a6d55e1da78390 100644 --- a/src/core/server/legacy/plugins/index.ts +++ b/src/core/server/legacy/plugins/index.ts @@ -16,4 +16,5 @@ * specific language governing permissions and limitations * under the License. */ + export { findLegacyPluginSpecs } from './find_legacy_plugin_specs'; diff --git a/src/core/server/legacy/types.ts b/src/core/server/legacy/types.ts new file mode 100644 index 00000000000000..6ec893be9b3103 --- /dev/null +++ b/src/core/server/legacy/types.ts @@ -0,0 +1,222 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Server } from 'hapi'; + +import { ChromeNavLink } from '../../public'; +import { LegacyRequest } from '../http'; +import { InternalCoreSetup, InternalCoreStart } from '../internal_types'; +import { PluginsServiceSetup, PluginsServiceStart } from '../plugins'; +import { RenderingServiceSetup } from '../rendering'; +import { SavedObjectsLegacyUiExports } from '../types'; + +/** + * @internal + * @deprecated + */ +export type LegacyVars = Record; + +type LegacyCoreSetup = InternalCoreSetup & { + plugins: PluginsServiceSetup; + rendering: RenderingServiceSetup; +}; +type LegacyCoreStart = InternalCoreStart & { plugins: PluginsServiceStart }; + +/** + * New platform representation of the legacy configuration (KibanaConfig) + * + * @internal + * @deprecated + */ +export interface LegacyConfig { + get(key?: string): T; + has(key: string): boolean; + set(key: string, value: any): void; + set(config: LegacyVars): void; +} + +/** + * Representation of a legacy configuration deprecation factory used for + * legacy plugin deprecations. + * + * @internal + * @deprecated + */ +export interface LegacyConfigDeprecationFactory { + rename(oldKey: string, newKey: string): LegacyConfigDeprecation; + unused(unusedKey: string): LegacyConfigDeprecation; +} + +/** + * Representation of a legacy configuration deprecation. + * + * @internal + * @deprecated + */ +export type LegacyConfigDeprecation = (settings: LegacyVars, log: (msg: string) => void) => void; + +/** + * Representation of a legacy configuration deprecation provider. + * + * @internal + * @deprecated + */ +export type LegacyConfigDeprecationProvider = ( + factory: LegacyConfigDeprecationFactory +) => LegacyConfigDeprecation[] | Promise; + +/** + * @internal + * @deprecated + */ +export interface LegacyPluginPack { + getPath(): string; +} + +/** + * @internal + * @deprecated + */ +export interface LegacyPluginSpec { + getId: () => unknown; + getExpectedKibanaVersion: () => string; + getConfigPrefix: () => string; + getDeprecationsProvider: () => LegacyConfigDeprecationProvider | undefined; +} + +/** + * @internal + * @deprecated + */ +export interface VarsProvider { + fn: (server: Server, configValue: any) => LegacyVars; + pluginSpec: { + readConfigValue(config: any, key: string | string[]): any; + }; +} + +/** + * @internal + * @deprecated + */ +export type VarsInjector = () => LegacyVars; + +/** + * @internal + * @deprecated + */ +export type VarsReplacer = ( + vars: LegacyVars, + request: LegacyRequest, + server: Server +) => LegacyVars | Promise; + +/** + * @internal + * @deprecated + */ +export type LegacyNavLinkSpec = Record & ChromeNavLink; + +/** + * @internal + * @deprecated + */ +export type LegacyAppSpec = Pick< + ChromeNavLink, + 'title' | 'order' | 'icon' | 'euiIconType' | 'url' | 'linkToLastSubUrl' | 'hidden' +> & { pluginId?: string; id?: string; listed?: boolean }; + +/** + * @internal + * @deprecated + */ +export type LegacyNavLink = Omit & { + order: number; +}; + +/** + * @internal + * @deprecated + */ +export type LegacyUiExports = SavedObjectsLegacyUiExports & { + defaultInjectedVarProviders?: VarsProvider[]; + injectedVarsReplacers?: VarsReplacer[]; + navLinkSpecs?: LegacyNavLinkSpec[] | null; + uiAppSpecs?: Array; + unknown?: [{ pluginSpec: LegacyPluginSpec; type: unknown }]; +}; + +/** + * @public + * @deprecated + */ +export interface LegacyServiceSetupDeps { + core: LegacyCoreSetup; + plugins: Record; +} + +/** + * @public + * @deprecated + */ +export interface LegacyServiceStartDeps { + core: LegacyCoreStart; + plugins: Record; +} + +/** + * @internal + * @deprecated + */ +export interface ILegacyInternals { + /** + * Inject UI app vars for a particular plugin + */ + injectUiAppVars(id: string, injector: VarsInjector): void; + + /** + * Get all the merged injected UI app vars for a particular plugin + */ + getInjectedUiAppVars(id: string): Promise; + + /** + * Get the metadata vars for a particular plugin + */ + getVars(id: string, request: LegacyRequest, injected?: LegacyVars): Promise; +} + +/** + * @internal + * @deprecated + */ +export interface LegacyPlugins { + disabledPluginSpecs: LegacyPluginSpec[]; + pluginSpecs: LegacyPluginSpec[]; + uiExports: LegacyUiExports; + navLinks: LegacyNavLink[]; +} + +/** + * @internal + * @deprecated + */ +export interface LegacyServiceDiscoverPlugins extends LegacyPlugins { + pluginExtendedConfig: LegacyConfig; + settings: LegacyVars; +} diff --git a/src/core/server/plugins/plugins_service.mock.ts b/src/core/server/plugins/plugins_service.mock.ts index 8d3c6a8c909a2f..5a52ebccbd472d 100644 --- a/src/core/server/plugins/plugins_service.mock.ts +++ b/src/core/server/plugins/plugins_service.mock.ts @@ -17,28 +17,28 @@ * under the License. */ -import { PluginsService } from './plugins_service'; +import { PluginsService, PluginsServiceSetup } from './plugins_service'; -type ServiceContract = PublicMethodsOf; -const createServiceMock = () => { - const mocked: jest.Mocked = { - discover: jest.fn(), - setup: jest.fn(), - start: jest.fn(), - stop: jest.fn(), - }; - mocked.setup.mockResolvedValue({ - contracts: new Map(), - uiPlugins: { - browserConfigs: new Map(), - internal: new Map(), - public: new Map(), - }, - }); - mocked.start.mockResolvedValue({ contracts: new Map() }); - return mocked; -}; +type PluginsServiceMock = jest.Mocked>; + +const createSetupContractMock = (): PluginsServiceSetup => ({ + contracts: new Map(), + uiPlugins: { + browserConfigs: new Map(), + internal: new Map(), + public: new Map(), + }, +}); +const createStartContractMock = () => ({ contracts: new Map() }); +const createServiceMock = (): PluginsServiceMock => ({ + discover: jest.fn(), + setup: jest.fn().mockResolvedValue(createSetupContractMock()), + start: jest.fn().mockResolvedValue(createStartContractMock()), + stop: jest.fn(), +}); export const pluginServiceMock = { create: createServiceMock, + createSetupContract: createSetupContractMock, + createStartContract: createStartContractMock, }; diff --git a/src/plugins/kibana_react/public/util/use_observable.ts b/src/core/server/rendering/__mocks__/params.ts similarity index 56% rename from src/plugins/kibana_react/public/util/use_observable.ts rename to src/core/server/rendering/__mocks__/params.ts index 6a7ce1f5290d28..392b2f0c5e2a4c 100644 --- a/src/plugins/kibana_react/public/util/use_observable.ts +++ b/src/core/server/rendering/__mocks__/params.ts @@ -17,18 +17,19 @@ * under the License. */ -import { useLayoutEffect, useState } from 'react'; -import { Observable } from 'rxjs'; +import { mockCoreContext } from '../../core_context.mock'; +import { httpServiceMock } from '../../http/http_service.mock'; +import { pluginServiceMock } from '../../plugins/plugins_service.mock'; +import { legacyServiceMock } from '../../legacy/legacy_service.mock'; -export function useObservable(observable$: Observable): T | undefined; -export function useObservable(observable$: Observable, initialValue: T): T; -export function useObservable(observable$: Observable, initialValue?: T): T | undefined { - const [value, update] = useState(initialValue); +const context = mockCoreContext.create(); +const http = httpServiceMock.createSetupContract(); +const plugins = pluginServiceMock.createSetupContract(); +const legacyPlugins = legacyServiceMock.createDiscoverPlugins(); - useLayoutEffect(() => { - const s = observable$.subscribe(update); - return () => s.unsubscribe(); - }, [observable$]); - - return value; -} +export const mockRenderingServiceParams = context; +export const mockRenderingSetupDeps = { + http, + legacyPlugins, + plugins, +}; diff --git a/src/core/server/rendering/__mocks__/rendering_service.ts b/src/core/server/rendering/__mocks__/rendering_service.ts new file mode 100644 index 00000000000000..33dca7cc0d30e5 --- /dev/null +++ b/src/core/server/rendering/__mocks__/rendering_service.ts @@ -0,0 +1,39 @@ +/* + * 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 { RenderingService as Service } from '../rendering_service'; +import { RenderingServiceSetup } from '../types'; +import { mockRenderingServiceParams } from './params'; + +type IRenderingService = PublicMethodsOf; + +export const setupMock: jest.Mocked = { + render: jest.fn(), +}; +export const mockSetup = jest.fn().mockResolvedValue(setupMock); +export const mockStart = jest.fn(); +export const mockStop = jest.fn(); +export const mockRenderingService: jest.Mocked = { + setup: mockSetup, + start: mockStart, + stop: mockStop, +}; +export const RenderingService = jest.fn( + () => mockRenderingService +); diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap new file mode 100644 index 00000000000000..edde1dee85f4f8 --- /dev/null +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -0,0 +1,719 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RenderingService setup() render() renders "core" from legacy request 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:core", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": false, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "core" page 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:core", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": false, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "core" page driven by settings 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:core", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object { + "theme:darkMode": Object { + "userValue": true, + }, + }, + }, + "version": Any, + }, + "legacyMode": false, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "core" page for blank basepath 1`] = ` +Object { + "basePath": "", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:core", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": false, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "core" with excluded user settings 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:core", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": false, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "legacy" page 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:legacy", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": true, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "legacy" page for blank basepath 1`] = ` +Object { + "basePath": "", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:legacy", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": true, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "legacy" with custom vars 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:legacy", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": true, + "uiPlugins": Array [], + "vars": Object { + "fake": "__TEST_TOKEN__", + }, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "legacy" with excluded user settings 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:legacy", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": true, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + +exports[`RenderingService setup() render() renders "legacy" with excluded user settings and custom vars 1`] = ` +Object { + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "binDir": Any, + "cliArgs": Object { + "basePath": false, + "dev": true, + "open": false, + "optimize": false, + "oss": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": Any, + "configs": Array [], + "homeDir": Any, + "isDevClusterMaster": false, + "logDir": Any, + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": false, + "version": Any, + }, + "pluginSearchPaths": Any, + "staticFilesDir": Any, + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "app": Object {}, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "bundleId": "app:legacy", + "devMode": true, + "nav": Array [], + "serverName": "http-server-test", + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + "version": Any, + }, + "legacyMode": true, + "uiPlugins": Array [], + "vars": Object { + "fake": "__TEST_TOKEN__", + }, + "version": Any, +} +`; diff --git a/src/plugins/kibana_react/public/util/use_unmount.ts b/src/core/server/rendering/index.ts similarity index 87% rename from src/plugins/kibana_react/public/util/use_unmount.ts rename to src/core/server/rendering/index.ts index 009bf8c4caa1c4..233f4b26a70dbb 100644 --- a/src/plugins/kibana_react/public/util/use_unmount.ts +++ b/src/core/server/rendering/index.ts @@ -17,8 +17,5 @@ * under the License. */ -import { useEffect } from 'react'; - -export function useUnmount(fn: () => void): void { - useEffect(() => fn, []); -} +export { RenderingService } from './rendering_service'; +export * from './types'; diff --git a/src/core/server/rendering/rendering_service.test.ts b/src/core/server/rendering/rendering_service.test.ts new file mode 100644 index 00000000000000..63145f2b30573a --- /dev/null +++ b/src/core/server/rendering/rendering_service.test.ts @@ -0,0 +1,185 @@ +/* + * 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 { load } from 'cheerio'; + +import { httpServerMock } from '../http/http_server.mocks'; +import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; +import { mockRenderingServiceParams, mockRenderingSetupDeps } from './__mocks__/params'; +import { RenderingServiceSetup } from './types'; +import { RenderingService } from './rendering_service'; + +const INJECTED_METADATA = { + version: expect.any(String), + branch: expect.any(String), + buildNumber: expect.any(Number), + env: { + binDir: expect.any(String), + configDir: expect.any(String), + homeDir: expect.any(String), + logDir: expect.any(String), + packageInfo: { + branch: expect.any(String), + buildNum: expect.any(Number), + buildSha: expect.any(String), + version: expect.any(String), + }, + pluginSearchPaths: expect.any(Array), + staticFilesDir: expect.any(String), + }, + legacyMetadata: { + branch: expect.any(String), + buildNum: expect.any(Number), + buildSha: expect.any(String), + version: expect.any(String), + }, +}; +const { createKibanaRequest, createRawRequest } = httpServerMock; +const legacyApp = { getId: () => 'legacy' }; + +describe('RenderingService', () => { + let service: RenderingService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new RenderingService(mockRenderingServiceParams); + }); + + describe('setup()', () => { + it('creates instance of RenderingServiceSetup', async () => { + const rendering = await service.setup(mockRenderingSetupDeps); + + expect(rendering.render).toBeInstanceOf(Function); + }); + + describe('render()', () => { + let uiSettings: ReturnType; + let render: RenderingServiceSetup['render']; + + beforeEach(async () => { + uiSettings = uiSettingsServiceMock.createClient(); + uiSettings.getRegistered.mockReturnValue({ + registered: { name: 'title' }, + }); + render = (await service.setup(mockRenderingSetupDeps)).render; + }); + + it('renders "core" page', async () => { + const content = await render(createKibanaRequest(), uiSettings); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "core" page for blank basepath', async () => { + mockRenderingSetupDeps.http.basePath.get.mockReturnValueOnce(''); + + const content = await render(createKibanaRequest(), uiSettings); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "core" page driven by settings', async () => { + uiSettings.getUserProvided.mockResolvedValue({ 'theme:darkMode': { userValue: true } }); + const content = await render(createKibanaRequest(), uiSettings); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "core" with excluded user settings', async () => { + const content = await render(createKibanaRequest(), uiSettings, { + includeUserSettings: false, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "core" from legacy request', async () => { + const content = await render(createRawRequest(), uiSettings); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "legacy" page', async () => { + const content = await render(createRawRequest(), uiSettings, { app: legacyApp }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "legacy" page for blank basepath', async () => { + mockRenderingSetupDeps.http.basePath.get.mockReturnValueOnce(''); + + const content = await render(createRawRequest(), uiSettings, { app: legacyApp }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "legacy" with custom vars', async () => { + const content = await render(createRawRequest(), uiSettings, { + app: legacyApp, + vars: { + fake: '__TEST_TOKEN__', + }, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "legacy" with excluded user settings', async () => { + const content = await render(createRawRequest(), uiSettings, { + app: legacyApp, + includeUserSettings: false, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + + it('renders "legacy" with excluded user settings and custom vars', async () => { + const content = await render(createRawRequest(), uiSettings, { + app: legacyApp, + includeUserSettings: false, + vars: { + fake: '__TEST_TOKEN__', + }, + }); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data')); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + }); + }); +}); diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx new file mode 100644 index 00000000000000..41810c6a106559 --- /dev/null +++ b/src/core/server/rendering/rendering_service.tsx @@ -0,0 +1,120 @@ +/* + * 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 React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { take } from 'rxjs/operators'; + +import { i18n } from '@kbn/i18n'; + +import { CoreService } from '../../types'; +import { CoreContext } from '../core_context'; +import { Template } from './views'; +import { + RenderingSetupDeps, + RenderingServiceSetup, + RenderingMetadata, + LegacyRenderOptions, +} from './types'; + +/** @internal */ +export class RenderingService implements CoreService { + constructor(private readonly coreContext: CoreContext) {} + + public async setup({ + http, + legacyPlugins, + plugins, + }: RenderingSetupDeps): Promise { + async function getUiConfig(pluginId: string) { + const browserConfig = plugins.uiPlugins.browserConfigs.get(pluginId); + + return ((await browserConfig?.pipe(take(1)).toPromise()) ?? {}) as Record; + } + + return { + render: async ( + request, + uiSettings, + { + app = { getId: () => 'core' }, + includeUserSettings = true, + vars = {}, + }: LegacyRenderOptions = {} + ) => { + const { env } = this.coreContext; + const basePath = http.basePath.get(request); + const settings = { + defaults: uiSettings.getRegistered(), + user: includeUserSettings ? await uiSettings.getUserProvided() : {}, + }; + const appId = app.getId(); + const metadata: RenderingMetadata = { + strictCsp: http.csp.strict, + uiPublicUrl: `${basePath}/ui`, + bootstrapScriptUrl: `${basePath}/bundles/app/${appId}/bootstrap.js`, + i18n: i18n.translate, + locale: i18n.getLocale(), + darkMode: settings.user?.['theme:darkMode']?.userValue + ? Boolean(settings.user['theme:darkMode'].userValue) + : false, + injectedMetadata: { + version: env.packageInfo.version, + buildNumber: env.packageInfo.buildNum, + branch: env.packageInfo.branch, + basePath, + env, + legacyMode: appId !== 'core', + i18n: { + translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, + }, + csp: { warnLegacyBrowsers: http.csp.warnLegacyBrowsers }, + vars, + uiPlugins: await Promise.all( + [...plugins.uiPlugins.public].map(async ([id, plugin]) => ({ + id, + plugin, + config: await getUiConfig(id), + })) + ), + legacyMetadata: { + app, + bundleId: `app:${appId}`, + nav: legacyPlugins.navLinks, + version: env.packageInfo.version, + branch: env.packageInfo.branch, + buildNum: env.packageInfo.buildNum, + buildSha: env.packageInfo.buildSha, + serverName: http.server.name, + devMode: env.mode.dev, + basePath, + uiSettings: settings, + }, + }, + }; + + return `${renderToStaticMarkup(