1256 lines
33 KiB
Text
1256 lines
33 KiB
Text
[[migrating-legacy-plugins-examples]]
|
||
== Migration Examples
|
||
|
||
This document is a list of examples of how to migrate plugin code from
|
||
legacy APIs to their {kib} Platform equivalents.
|
||
|
||
[[config-migration]]
|
||
=== Configuration
|
||
==== Declaring config schema
|
||
|
||
Declaring the schema of your configuration fields is similar to the
|
||
Legacy Platform, but uses the `@kbn/config-schema` package instead of
|
||
Joi. This package has full TypeScript support out-of-the-box.
|
||
|
||
*Legacy config schema*
|
||
[source,typescript]
|
||
----
|
||
import Joi from 'joi';
|
||
|
||
new kibana.Plugin({
|
||
config() {
|
||
return Joi.object({
|
||
enabled: Joi.boolean().default(true),
|
||
defaultAppId: Joi.string().default('home'),
|
||
index: Joi.string().default('.kibana'),
|
||
disableWelcomeScreen: Joi.boolean().default(false),
|
||
autocompleteTerminateAfter: Joi.number().integer().min(1).default(100000),
|
||
})
|
||
}
|
||
});
|
||
----
|
||
|
||
*{kib} Platform equivalent*
|
||
[source,typescript]
|
||
----
|
||
import { schema, TypeOf } from '@kbn/config-schema';
|
||
|
||
export const config = {
|
||
schema: schema.object({
|
||
enabled: schema.boolean({ defaultValue: true }),
|
||
defaultAppId: schema.string({ defaultValue: true }),
|
||
index: schema.string({ defaultValue: '.kibana' }),
|
||
disableWelcomeScreen: schema.boolean({ defaultValue: false }),
|
||
autocompleteTerminateAfter: schema.duration({ min: 1, defaultValue: 100000 }),
|
||
})
|
||
};
|
||
|
||
// @kbn/config-schema is written in TypeScript, so you can use your schema
|
||
// definition to create a type to use in your plugin code.
|
||
export type MyPluginConfig = TypeOf<typeof config.schema>;
|
||
----
|
||
|
||
==== Using {kib} config in a new plugin
|
||
|
||
After setting the config schema for your plugin, you might want to read
|
||
configuration values from your plugin. It is provided as part of the
|
||
{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md[PluginInitializerContext]
|
||
in the _constructor_ of the plugin:
|
||
|
||
*plugins/my_plugin/(public|server)/index.ts*
|
||
[source,typescript]
|
||
----
|
||
import type { PluginInitializerContext } from 'kibana/server';
|
||
import { MyPlugin } from './plugin';
|
||
|
||
export function plugin(initializerContext: PluginInitializerContext) {
|
||
return new MyPlugin(initializerContext);
|
||
}
|
||
----
|
||
|
||
*plugins/my_plugin/(public|server)/plugin.ts*
|
||
[source,typescript]
|
||
----
|
||
import { CoreSetup, Logger, Plugin, PluginInitializerContext, PluginName } from 'kibana/server';
|
||
import type { MyPluginConfig } from './config';
|
||
|
||
export class MyPlugin implements Plugin {
|
||
private readonly config: MyPluginConfig;
|
||
private readonly log: Logger;
|
||
|
||
constructor(private readonly initializerContext: PluginInitializerContext) {
|
||
this.log = initializerContext.logger.get();
|
||
this.config = initializerContext.config.get<MyPluginConfig>();
|
||
}
|
||
|
||
public setup(core: CoreSetup, deps: Record<PluginName, unknown>) {
|
||
const { someConfigValue } = this.config;
|
||
}
|
||
}
|
||
----
|
||
|
||
Additionally, some plugins need to access the runtime env configuration.
|
||
|
||
[source,typescript]
|
||
----
|
||
export class MyPlugin implements Plugin {
|
||
public setup(core: CoreSetup, deps: Record<PluginName, unknown>) {
|
||
const { mode: { dev }, packageInfo: { version } } = this.initializerContext.env
|
||
}
|
||
----
|
||
|
||
=== Creating a {kib} Platform plugin
|
||
|
||
For example, if you want to move the legacy `demoplugin` plugin's
|
||
configuration to the {kib} Platform, you could create the {kib} Platform plugin with the
|
||
same name in `plugins/demoplugin` with the following files:
|
||
|
||
*plugins/demoplugin/kibana.json*
|
||
[source,json5]
|
||
----
|
||
{
|
||
"id": "demoplugin",
|
||
"server": true
|
||
}
|
||
----
|
||
|
||
*plugins/demoplugin/server/index.ts*
|
||
[source,typescript]
|
||
----
|
||
import { schema, TypeOf } from '@kbn/config-schema';
|
||
import type { PluginInitializerContext } from 'kibana/server';
|
||
import { DemoPlugin } from './plugin';
|
||
|
||
export const config = {
|
||
schema: schema.object({
|
||
enabled: schema.boolean({ defaultValue: true }),
|
||
});
|
||
}
|
||
|
||
export const plugin = (initContext: PluginInitializerContext) => new DemoPlugin(initContext);
|
||
|
||
export type DemoPluginConfig = TypeOf<typeof config.schema>;
|
||
export { DemoPluginSetup } from './plugin';
|
||
----
|
||
|
||
*plugins/demoplugin/server/plugin.ts*
|
||
[source,typescript]
|
||
----
|
||
import type { PluginInitializerContext, Plugin, CoreSetup } from 'kibana/server';
|
||
import type { DemoPluginConfig } from '.';
|
||
export interface DemoPluginSetup {};
|
||
|
||
export class DemoPlugin implements Plugin<DemoPluginSetup> {
|
||
constructor(private readonly initContext: PluginInitializerContext) {}
|
||
|
||
public setup(core: CoreSetup) {
|
||
return {};
|
||
}
|
||
|
||
public start() {}
|
||
public stop() {}
|
||
}
|
||
----
|
||
|
||
[[http-routes-migration]]
|
||
=== HTTP Routes
|
||
|
||
In the legacy platform, plugins have direct access to the Hapi `server`
|
||
object, which gives full access to all of Hapi’s API. In the New
|
||
Platform, plugins have access to the
|
||
{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md[HttpServiceSetup]
|
||
interface, which is exposed via the
|
||
{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.coresetup.md[CoreSetup]
|
||
object injected into the `setup` method of server-side plugins.
|
||
|
||
This interface has a different API with slightly different behaviors.
|
||
|
||
* All input (body, query parameters, and URL parameters) must be
|
||
validated using the `@kbn/config-schema` package. If no validation
|
||
schema is provided, these values will be empty objects.
|
||
* All exceptions thrown by handlers result in 500 errors. If you need a
|
||
specific HTTP error code, catch any exceptions in your handler and
|
||
construct the appropriate response using the provided response factory.
|
||
While you can continue using the `Boom` module internally in your
|
||
plugin, the framework does not have native support for converting Boom
|
||
exceptions into HTTP responses.
|
||
|
||
Migrate legacy route registration:
|
||
*legacy/plugins/demoplugin/index.ts*
|
||
[source,typescript]
|
||
----
|
||
import Joi from 'joi';
|
||
|
||
new kibana.Plugin({
|
||
init(server) {
|
||
server.route({
|
||
path: '/api/demoplugin/search',
|
||
method: 'POST',
|
||
options: {
|
||
validate: {
|
||
payload: Joi.object({
|
||
field1: Joi.string().required(),
|
||
}),
|
||
}
|
||
},
|
||
handler(req, h) {
|
||
return { message: `Received field1: ${req.payload.field1}` };
|
||
}
|
||
});
|
||
}
|
||
});
|
||
----
|
||
to the {kib} platform format:
|
||
*plugins/demoplugin/server/plugin.ts*
|
||
[source,typescript]
|
||
----
|
||
import { schema } from '@kbn/config-schema';
|
||
import type { CoreSetup } from 'kibana/server';
|
||
|
||
export class DemoPlugin {
|
||
public setup(core: CoreSetup) {
|
||
const router = core.http.createRouter();
|
||
router.post(
|
||
{
|
||
path: '/api/demoplugin/search',
|
||
validate: {
|
||
body: schema.object({
|
||
field1: schema.string(),
|
||
}),
|
||
}
|
||
},
|
||
(context, req, res) => {
|
||
return res.ok({
|
||
body: {
|
||
message: `Received field1: ${req.body.field1}`
|
||
}
|
||
});
|
||
}
|
||
)
|
||
}
|
||
}
|
||
----
|
||
|
||
If your plugin still relies on throwing Boom errors from routes, you can
|
||
use the `router.handleLegacyErrors` as a temporary solution until error
|
||
migration is complete:
|
||
|
||
*plugins/demoplugin/server/plugin.ts*
|
||
[source,typescript]
|
||
----
|
||
import { schema } from '@kbn/config-schema';
|
||
import { CoreSetup } from 'kibana/server';
|
||
import Boom from '@hapi/boom';
|
||
|
||
export class DemoPlugin {
|
||
public setup(core: CoreSetup) {
|
||
const router = core.http.createRouter();
|
||
router.post(
|
||
{
|
||
path: '/api/demoplugin/search',
|
||
validate: {
|
||
body: schema.object({
|
||
field1: schema.string(),
|
||
}),
|
||
}
|
||
},
|
||
router.handleLegacyErrors((context, req, res) => {
|
||
throw Boom.notFound('not there'); // will be converted into proper Platform error
|
||
})
|
||
)
|
||
}
|
||
}
|
||
----
|
||
|
||
=== Accessing Services
|
||
|
||
Services in the Legacy Platform were typically available via methods on
|
||
either `server.plugins.*`, `server.*`, or `req.*`. In the {kib} Platform,
|
||
all services are available via the `context` argument to the route
|
||
handler. The type of this argument is the
|
||
{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md[RequestHandlerContext].
|
||
The APIs available here will include all Core services and any services registered by plugins this plugin depends on.
|
||
|
||
*legacy/plugins/demoplugin/index.ts*
|
||
[source,typescript]
|
||
----
|
||
new kibana.Plugin({
|
||
init(server) {
|
||
const { callWithRequest } = server.plugins.elasticsearch.getCluster('data');
|
||
|
||
server.route({
|
||
path: '/api/my-plugin/my-route',
|
||
method: 'POST',
|
||
async handler(req, h) {
|
||
const results = await callWithRequest(req, 'search', query);
|
||
return { results };
|
||
}
|
||
});
|
||
}
|
||
});
|
||
----
|
||
|
||
*plugins/demoplugin/server/plugin.ts*
|
||
[source,typescript]
|
||
----
|
||
export class DemoPlugin {
|
||
public setup(core) {
|
||
const router = core.http.createRouter();
|
||
router.post(
|
||
{
|
||
path: '/api/my-plugin/my-route',
|
||
},
|
||
async (context, req, res) => {
|
||
const results = await context.core.elasticsearch.client.asCurrentUser.search(query);
|
||
return res.ok({
|
||
body: { results }
|
||
});
|
||
}
|
||
)
|
||
}
|
||
}
|
||
----
|
||
|
||
=== Migrating Hapi pre-handlers
|
||
|
||
In the Legacy Platform, routes could provide a `pre` option in their
|
||
config to register a function that should be run before the route
|
||
handler. These `pre` handlers allow routes to share some business
|
||
logic that may do some pre-work or validation. In {kib}, these are
|
||
often used for license checks.
|
||
|
||
The {kib} Platform’s HTTP interface does not provide this
|
||
functionality. However, it is simple enough to port over using
|
||
a higher-order function that can wrap the route handler.
|
||
|
||
==== Simple example
|
||
|
||
In this simple example, a pre-handler is used to either abort the
|
||
request with an error or continue as normal. This is a simple
|
||
`gate-keeping` pattern.
|
||
|
||
[source,typescript]
|
||
----
|
||
// Legacy pre-handler
|
||
const licensePreRouting = (request) => {
|
||
const licenseInfo = getMyPluginLicenseInfo(request.server.plugins.xpack_main);
|
||
if (!licenseInfo.isOneOf(['gold', 'platinum', 'trial'])) {
|
||
throw Boom.forbidden(`You don't have the right license for MyPlugin!`);
|
||
}
|
||
}
|
||
|
||
server.route({
|
||
method: 'GET',
|
||
path: '/api/my-plugin/do-something',
|
||
config: {
|
||
pre: [{ method: licensePreRouting }]
|
||
},
|
||
handler: (req) => {
|
||
return doSomethingInteresting();
|
||
}
|
||
})
|
||
----
|
||
|
||
In the {kib} Platform, the same functionality can be achieved by
|
||
creating a function that takes a route handler (or factory for a route
|
||
handler) as an argument and either successfully invokes it or
|
||
returns an error response.
|
||
|
||
This a `high-order handler` similar to the `high-order
|
||
component` pattern common in the React ecosystem.
|
||
|
||
[source,typescript]
|
||
----
|
||
// Kibana Platform high-order handler
|
||
const checkLicense = <P, Q, B>(
|
||
handler: RequestHandler<P, Q, B, RouteMethod>
|
||
): RequestHandler<P, Q, B, RouteMethod> => {
|
||
return (context, req, res) => {
|
||
const licenseInfo = getMyPluginLicenseInfo(context.licensing.license);
|
||
|
||
if (licenseInfo.hasAtLeast('gold')) {
|
||
return handler(context, req, res);
|
||
} else {
|
||
return res.forbidden({ body: `You don't have the right license for MyPlugin!` });
|
||
}
|
||
}
|
||
}
|
||
|
||
router.get(
|
||
{ path: '/api/my-plugin/do-something', validate: false },
|
||
checkLicense(async (context, req, res) => {
|
||
const results = doSomethingInteresting();
|
||
return res.ok({ body: results });
|
||
}),
|
||
)
|
||
----
|
||
|
||
==== Full Example
|
||
|
||
In some cases, the route handler may need access to data that the
|
||
pre-handler retrieves. In this case, you can utilize a handler _factory_
|
||
rather than a raw handler.
|
||
|
||
[source,typescript]
|
||
----
|
||
// Legacy pre-handler
|
||
const licensePreRouting = (request) => {
|
||
const licenseInfo = getMyPluginLicenseInfo(request.server.plugins.xpack_main);
|
||
if (licenseInfo.isOneOf(['gold', 'platinum', 'trial'])) {
|
||
// In this case, the return value of the pre-handler is made available on
|
||
// whatever the 'assign' option is in the route config.
|
||
return licenseInfo;
|
||
} else {
|
||
// In this case, the route handler is never called and the user gets this
|
||
// error message
|
||
throw Boom.forbidden(`You don't have the right license for MyPlugin!`);
|
||
}
|
||
}
|
||
|
||
server.route({
|
||
method: 'GET',
|
||
path: '/api/my-plugin/do-something',
|
||
config: {
|
||
pre: [{ method: licensePreRouting, assign: 'licenseInfo' }]
|
||
},
|
||
handler: (req) => {
|
||
const licenseInfo = req.pre.licenseInfo;
|
||
return doSomethingInteresting(licenseInfo);
|
||
}
|
||
})
|
||
----
|
||
|
||
In many cases, it may be simpler to duplicate the function call to
|
||
retrieve the data again in the main handler. In other cases, you
|
||
can utilize a handler _factory_ rather than a raw handler as the
|
||
argument to your high-order handler. This way, the high-order handler can
|
||
pass arbitrary arguments to the route handler.
|
||
|
||
[source,typescript]
|
||
----
|
||
// Kibana Platform high-order handler
|
||
const checkLicense = <P, Q, B>(
|
||
handlerFactory: (licenseInfo: MyPluginLicenseInfo) => RequestHandler<P, Q, B, RouteMethod>
|
||
): RequestHandler<P, Q, B, RouteMethod> => {
|
||
return (context, req, res) => {
|
||
const licenseInfo = getMyPluginLicenseInfo(context.licensing.license);
|
||
|
||
if (licenseInfo.hasAtLeast('gold')) {
|
||
const handler = handlerFactory(licenseInfo);
|
||
return handler(context, req, res);
|
||
} else {
|
||
return res.forbidden({ body: `You don't have the right license for MyPlugin!` });
|
||
}
|
||
}
|
||
}
|
||
|
||
router.get(
|
||
{ path: '/api/my-plugin/do-something', validate: false },
|
||
checkLicense(licenseInfo => async (context, req, res) => {
|
||
const results = doSomethingInteresting(licenseInfo);
|
||
return res.ok({ body: results });
|
||
}),
|
||
)
|
||
----
|
||
|
||
=== Chrome
|
||
|
||
In the Legacy Platform, the `ui/chrome` import contained APIs for a very
|
||
wide range of features. In the {kib} Platform, some of these APIs have
|
||
changed or moved elsewhere. See <<client-side-core-migration-table>>.
|
||
|
||
==== Updating an application navlink
|
||
|
||
In the legacy platform, the navlink could be updated using
|
||
`chrome.navLinks.update`.
|
||
|
||
[source,typescript]
|
||
----
|
||
uiModules.get('xpack/ml').run(() => {
|
||
const showAppLink = xpackInfo.get('features.ml.showLinks', false);
|
||
const isAvailable = xpackInfo.get('features.ml.isAvailable', false);
|
||
|
||
const navLinkUpdates = {
|
||
// hide by default, only show once the xpackInfo is initialized
|
||
hidden: !showAppLink,
|
||
disabled: !showAppLink || (showAppLink && !isAvailable),
|
||
};
|
||
|
||
npStart.core.chrome.navLinks.update('ml', navLinkUpdates);
|
||
});
|
||
----
|
||
|
||
In the {kib} Platform, navlinks should not be updated directly. Instead,
|
||
it is now possible to add an `updater` when registering an application
|
||
to change the application or the navlink state at runtime.
|
||
|
||
[source,typescript]
|
||
----
|
||
// my_plugin has a required dependencie to the `licensing` plugin
|
||
interface MyPluginSetupDeps {
|
||
licensing: LicensingPluginSetup;
|
||
}
|
||
|
||
export class MyPlugin implements Plugin {
|
||
setup({ application }, { licensing }: MyPluginSetupDeps) {
|
||
const updater$ = licensing.license$.pipe(
|
||
map(license => {
|
||
const { hidden, disabled } = calcStatusFor(license);
|
||
if (hidden) return { navLinkStatus: AppNavLinkStatus.hidden };
|
||
if (disabled) return { navLinkStatus: AppNavLinkStatus.disabled };
|
||
return { navLinkStatus: AppNavLinkStatus.default };
|
||
})
|
||
);
|
||
|
||
application.register({
|
||
id: 'my-app',
|
||
title: 'My App',
|
||
updater$,
|
||
async mount(params) {
|
||
const { renderApp } = await import('./application');
|
||
return renderApp(params);
|
||
},
|
||
});
|
||
}
|
||
----
|
||
|
||
=== Chromeless Applications
|
||
|
||
In {kib}, a `chromeless` application is one where the primary {kib}
|
||
UI components such as header or navigation can be hidden. In the legacy
|
||
platform, these were referred to as `hidden` applications and were set
|
||
via the `hidden` property in a {kib} plugin. Chromeless applications
|
||
are also not displayed in the left navbar.
|
||
|
||
To mark an application as chromeless, specify `chromeless: false` when
|
||
registering your application to hide the chrome UI when the application
|
||
is mounted:
|
||
|
||
[source,typescript]
|
||
----
|
||
application.register({
|
||
id: 'chromeless',
|
||
chromeless: true,
|
||
async mount(context, params) {
|
||
/* ... */
|
||
},
|
||
});
|
||
----
|
||
|
||
If you wish to render your application at a route that does not follow
|
||
the `/app/${appId}` pattern, this can be done via the `appRoute`
|
||
property. Doing this currently requires you to register a server route
|
||
where you can return a bootstrapped HTML page for your application
|
||
bundle.
|
||
|
||
[source,typescript]
|
||
----
|
||
application.register({
|
||
id: 'chromeless',
|
||
appRoute: '/chromeless',
|
||
chromeless: true,
|
||
async mount(context, params) {
|
||
/* ... */
|
||
},
|
||
});
|
||
----
|
||
|
||
[[render-html-migration]]
|
||
=== Render HTML Content
|
||
|
||
You can return a blank HTML page bootstrapped with the core application
|
||
bundle from an HTTP route handler via the `httpResources` service. You
|
||
may wish to do this if you are rendering a chromeless application with a
|
||
custom application route or have other custom rendering needs.
|
||
|
||
[source,typescript]
|
||
----
|
||
httpResources.register(
|
||
{ path: '/chromeless', validate: false },
|
||
(context, request, response) => {
|
||
//... some logic
|
||
return response.renderCoreApp();
|
||
}
|
||
);
|
||
----
|
||
|
||
You can also exclude user data from the bundle metadata. User
|
||
data comprises all UI Settings that are _user provided_, then injected
|
||
into the page. You may wish to exclude fetching this data if not
|
||
authorized or to slim the page size.
|
||
|
||
[source,typescript]
|
||
----
|
||
httpResources.register(
|
||
{ path: '/', validate: false, options: { authRequired: false } },
|
||
(context, request, response) => {
|
||
//... some logic
|
||
return response.renderAnonymousCoreApp();
|
||
}
|
||
);
|
||
----
|
||
|
||
[[saved-objects-migration]]
|
||
=== Saved Objects types
|
||
|
||
In the legacy platform, saved object types were registered using static
|
||
definitions in the `uiExports` part of the plugin manifest.
|
||
|
||
In the {kib} Platform, all these registrations are performed
|
||
programmatically during your plugin’s `setup` phase, using the core
|
||
`savedObjects`’s `registerType` setup API.
|
||
|
||
The most notable difference is that in the {kib} Platform, the type
|
||
registration is performed in a single call to `registerType`, passing a
|
||
new `SavedObjectsType` structure that is a superset of the legacy
|
||
`schema`, `migrations` `mappings` and `savedObjectsManagement`.
|
||
|
||
==== Concrete example
|
||
|
||
Suppose you have the following in a legacy plugin:
|
||
|
||
*legacy/plugins/demoplugin/index.ts*
|
||
[source,js]
|
||
----
|
||
import mappings from './mappings.json';
|
||
import { migrations } from './migrations';
|
||
|
||
new kibana.Plugin({
|
||
init(server){
|
||
// [...]
|
||
},
|
||
uiExports: {
|
||
mappings,
|
||
migrations,
|
||
savedObjectSchemas: {
|
||
'first-type': {
|
||
isNamespaceAgnostic: true,
|
||
},
|
||
'second-type': {
|
||
isHidden: true,
|
||
},
|
||
},
|
||
savedObjectsManagement: {
|
||
'first-type': {
|
||
isImportableAndExportable: true,
|
||
icon: 'myFirstIcon',
|
||
defaultSearchField: 'title',
|
||
getTitle(obj) {
|
||
return obj.attributes.title;
|
||
},
|
||
getEditUrl(obj) {
|
||
return `/some-url/${encodeURIComponent(obj.id)}`;
|
||
},
|
||
},
|
||
'second-type': {
|
||
isImportableAndExportable: false,
|
||
icon: 'mySecondIcon',
|
||
getTitle(obj) {
|
||
return obj.attributes.myTitleField;
|
||
},
|
||
getInAppUrl(obj) {
|
||
return {
|
||
path: `/some-url/${encodeURIComponent(obj.id)}`,
|
||
uiCapabilitiesPath: 'myPlugin.myType.show',
|
||
};
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
----
|
||
|
||
*legacy/plugins/demoplugin/mappings.json*
|
||
[source,json]
|
||
----
|
||
{
|
||
"first-type": {
|
||
"properties": {
|
||
"someField": {
|
||
"type": "text"
|
||
},
|
||
"anotherField": {
|
||
"type": "text"
|
||
}
|
||
}
|
||
},
|
||
"second-type": {
|
||
"properties": {
|
||
"textField": {
|
||
"type": "text"
|
||
},
|
||
"boolField": {
|
||
"type": "boolean"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
----
|
||
*legacy/plugins/demoplugin/migrations.js*
|
||
[source,js]
|
||
----
|
||
export const migrations = {
|
||
'first-type': {
|
||
'1.0.0': migrateFirstTypeToV1,
|
||
'2.0.0': migrateFirstTypeToV2,
|
||
},
|
||
'second-type': {
|
||
'1.5.0': migrateSecondTypeToV15,
|
||
}
|
||
}
|
||
----
|
||
|
||
To migrate this, you have to regroup the declaration per-type.
|
||
|
||
First type:
|
||
*plugins/demoplugin/server/saved_objects/first_type.ts*
|
||
[source,typescript]
|
||
----
|
||
import type { SavedObjectsType } from 'kibana/server';
|
||
|
||
export const firstType: SavedObjectsType = {
|
||
name: 'first-type',
|
||
hidden: false,
|
||
namespaceType: 'agnostic',
|
||
mappings: {
|
||
properties: {
|
||
someField: {
|
||
type: 'text',
|
||
},
|
||
anotherField: {
|
||
type: 'text',
|
||
},
|
||
},
|
||
},
|
||
migrations: {
|
||
'1.0.0': migrateFirstTypeToV1,
|
||
'2.0.0': migrateFirstTypeToV2,
|
||
},
|
||
management: {
|
||
importableAndExportable: true,
|
||
icon: 'myFirstIcon',
|
||
defaultSearchField: 'title',
|
||
getTitle(obj) {
|
||
return obj.attributes.title;
|
||
},
|
||
getEditUrl(obj) {
|
||
return `/some-url/${encodeURIComponent(obj.id)}`;
|
||
},
|
||
},
|
||
};
|
||
----
|
||
|
||
Second type:
|
||
*plugins/demoplugin/server/saved_objects/second_type.ts*
|
||
[source,typescript]
|
||
----
|
||
import type { SavedObjectsType } from 'kibana/server';
|
||
|
||
export const secondType: SavedObjectsType = {
|
||
name: 'second-type',
|
||
hidden: true,
|
||
namespaceType: 'single',
|
||
mappings: {
|
||
properties: {
|
||
textField: {
|
||
type: 'text',
|
||
},
|
||
boolField: {
|
||
type: 'boolean',
|
||
},
|
||
},
|
||
},
|
||
migrations: {
|
||
'1.5.0': migrateSecondTypeToV15,
|
||
},
|
||
management: {
|
||
importableAndExportable: false,
|
||
icon: 'mySecondIcon',
|
||
getTitle(obj) {
|
||
return obj.attributes.myTitleField;
|
||
},
|
||
getInAppUrl(obj) {
|
||
return {
|
||
path: `/some-url/${encodeURIComponent(obj.id)}`,
|
||
uiCapabilitiesPath: 'myPlugin.myType.show',
|
||
};
|
||
},
|
||
},
|
||
};
|
||
----
|
||
|
||
Registration in the plugin’s setup phase:
|
||
*plugins/demoplugin/server/plugin.ts*
|
||
[source,typescript]
|
||
----
|
||
import { firstType, secondType } from './saved_objects';
|
||
|
||
export class DemoPlugin implements Plugin {
|
||
setup({ savedObjects }) {
|
||
savedObjects.registerType(firstType);
|
||
savedObjects.registerType(secondType);
|
||
}
|
||
}
|
||
----
|
||
|
||
==== Changes in structure compared to legacy
|
||
|
||
The {kib} Platform `registerType` expected input is very close to the legacy format.
|
||
However, there are some minor changes:
|
||
|
||
* The `schema.isNamespaceAgnostic` property has been renamed:
|
||
`SavedObjectsType.namespaceType`. It no longer accepts a boolean but
|
||
instead an enum of `single`, `multiple`, `multiple-isolated`, or `agnostic` (see
|
||
{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md[SavedObjectsNamespaceType]).
|
||
* The `schema.indexPattern` was accepting either a `string` or a
|
||
`(config: LegacyConfig) => string`. `SavedObjectsType.indexPattern` only
|
||
accepts a string, as you can access the configuration during your
|
||
plugin’s setup phase.
|
||
* The `savedObjectsManagement.isImportableAndExportable` property has
|
||
been renamed: `SavedObjectsType.management.importableAndExportable`.
|
||
* The migration function signature has changed: In legacy, it used to be
|
||
[source,typescript]
|
||
----
|
||
`(doc: SavedObjectUnsanitizedDoc, log: SavedObjectsMigrationLogger) => SavedObjectUnsanitizedDoc;`
|
||
----
|
||
In {kib} Platform, it is
|
||
[source,typescript]
|
||
----
|
||
`(doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => SavedObjectUnsanitizedDoc;`
|
||
----
|
||
|
||
With context being:
|
||
|
||
[source,typescript]
|
||
----
|
||
export interface SavedObjectMigrationContext {
|
||
log: SavedObjectsMigrationLogger;
|
||
}
|
||
----
|
||
|
||
The changes is very minor though. The legacy migration:
|
||
|
||
[source,js]
|
||
----
|
||
const migration = (doc, log) => {...}
|
||
----
|
||
|
||
Would be converted to:
|
||
|
||
[source,typescript]
|
||
----
|
||
const migration: SavedObjectMigrationFn<OldAttributes, MigratedAttributes> = (doc, { log }) => {...}
|
||
----
|
||
|
||
=== UiSettings
|
||
|
||
UiSettings defaults registration performed during `setup` phase via
|
||
`core.uiSettings.register` API.
|
||
|
||
*legacy/plugins/demoplugin/index.js*
|
||
[source,js]
|
||
----
|
||
uiExports: {
|
||
uiSettingDefaults: {
|
||
'my-plugin:my-setting': {
|
||
name: 'just-work',
|
||
value: true,
|
||
description: 'make it work',
|
||
category: ['my-category'],
|
||
},
|
||
}
|
||
}
|
||
----
|
||
|
||
*plugins/demoplugin/server/plugin.ts*
|
||
[source,typescript]
|
||
----
|
||
setup(core: CoreSetup){
|
||
core.uiSettings.register({
|
||
'my-plugin:my-setting': {
|
||
name: 'just-work',
|
||
value: true,
|
||
description: 'make it work',
|
||
category: ['my-category'],
|
||
schema: schema.boolean(),
|
||
},
|
||
})
|
||
}
|
||
----
|
||
|
||
=== Elasticsearch client
|
||
|
||
The new elasticsearch client is a thin wrapper around
|
||
`@elastic/elasticsearch`’s `Client` class. Even if the API is quite
|
||
close to the legacy client {kib} was previously using, there are some
|
||
subtle changes to take into account during migration.
|
||
|
||
https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html[Official
|
||
client documentation]
|
||
|
||
==== Client API Changes
|
||
|
||
Refer to the
|
||
https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/breaking-changes.html[Breaking
|
||
changes list] for more information about the changes between the legacy
|
||
and new client.
|
||
|
||
The most significant changes on the Kibana side for the consumers are the following:
|
||
|
||
===== User client accessor
|
||
Internal /current user client accessors has been renamed and are now
|
||
properties instead of functions:
|
||
|
||
* `callAsInternalUser('ping')` -> `asInternalUser.ping()`
|
||
* `callAsCurrentUser('ping')` -> `asCurrentUser.ping()`
|
||
* the API now reflects the `Client`’s instead of leveraging the
|
||
string-based endpoint names the `LegacyAPICaller` was using.
|
||
|
||
Before:
|
||
|
||
[source,typescript]
|
||
----
|
||
const body = await client.callAsInternalUser('indices.get', { index: 'id' });
|
||
----
|
||
|
||
After:
|
||
|
||
[source,typescript]
|
||
----
|
||
const { body } = await client.asInternalUser.indices.get({ index: 'id' });
|
||
----
|
||
|
||
===== Response object
|
||
Calling any ES endpoint now returns the whole response object instead
|
||
of only the body payload.
|
||
|
||
Before:
|
||
|
||
[source,typescript]
|
||
----
|
||
const body = await legacyClient.callAsInternalUser('get', { id: 'id' });
|
||
----
|
||
|
||
After:
|
||
|
||
[source,typescript]
|
||
----
|
||
const { body } = await client.asInternalUser.get({ id: 'id' });
|
||
----
|
||
|
||
Note that more information from the ES response is available:
|
||
|
||
[source,typescript]
|
||
----
|
||
const {
|
||
body, // response payload
|
||
statusCode, // http status code of the response
|
||
headers, // response headers
|
||
warnings, // warnings returned from ES
|
||
meta // meta information about the request, such as request parameters, number of attempts and so on
|
||
} = await client.asInternalUser.get({ id: 'id' });
|
||
----
|
||
|
||
===== Response Type
|
||
All API methods are now generic to allow specifying the response body.
|
||
type
|
||
|
||
Before:
|
||
|
||
[source,typescript]
|
||
----
|
||
const body: GetResponse = await legacyClient.callAsInternalUser('get', { id: 'id' });
|
||
----
|
||
|
||
After:
|
||
|
||
[source,typescript]
|
||
----
|
||
// body is of type `GetResponse`
|
||
const { body } = await client.asInternalUser.get<GetResponse>({ id: 'id' });
|
||
// fallback to `Record<string, any>` if unspecified
|
||
const { body } = await client.asInternalUser.get({ id: 'id' });
|
||
----
|
||
|
||
The new client doesn’t provide exhaustive typings for the response
|
||
object yet. You might have to copy response type definitions from the
|
||
Legacy Elasticsearch library until the additional announcements.
|
||
|
||
[source,typescript]
|
||
----
|
||
// Kibana provides a few typings for internal purposes
|
||
import type { SearchResponse } from 'kibana/server';
|
||
type SearchSource = {...};
|
||
type SearchBody = SearchResponse<SearchSource>;
|
||
const { body } = await client.search<SearchBody>(...);
|
||
interface Info {...}
|
||
const { body } = await client.info<Info>(...);
|
||
----
|
||
|
||
===== Errors
|
||
The returned error types changed.
|
||
|
||
There are no longer specific errors for every HTTP status code (such as
|
||
`BadRequest` or `NotFound`). A generic `ResponseError` with the specific
|
||
`statusCode` is thrown instead.
|
||
|
||
Before:
|
||
|
||
[source,typescript]
|
||
----
|
||
import { errors } from 'elasticsearch';
|
||
try {
|
||
await legacyClient.callAsInternalUser('ping');
|
||
} catch(e) {
|
||
if(e instanceof errors.NotFound) {
|
||
// do something
|
||
}
|
||
if(e.status === 401) {}
|
||
}
|
||
----
|
||
|
||
After:
|
||
|
||
[source,typescript]
|
||
----
|
||
import { errors } from '@elastic/elasticsearch';
|
||
try {
|
||
await client.asInternalUser.ping();
|
||
} catch(e) {
|
||
if(e instanceof errors.ResponseError && e.statusCode === 404) {
|
||
// do something
|
||
}
|
||
// also possible, as all errors got a name property with the name of the class,
|
||
// so this slightly better in term of performances
|
||
if(e.name === 'ResponseError' && e.statusCode === 404) {
|
||
// do something
|
||
}
|
||
if(e.statusCode === 401) {...}
|
||
}
|
||
----
|
||
|
||
===== Parameter naming format
|
||
The parameter property names changed from camelCase to snake_case
|
||
|
||
Even if technically, the JavaScript client accepts both formats, the
|
||
TypeScript definitions are only defining snake_case properties.
|
||
|
||
Before:
|
||
|
||
[source,typescript]
|
||
----
|
||
legacyClient.callAsCurrentUser('get', {
|
||
id: 'id',
|
||
storedFields: ['some', 'fields'],
|
||
})
|
||
----
|
||
|
||
After:
|
||
|
||
[source,typescript]
|
||
----
|
||
client.asCurrentUser.get({
|
||
id: 'id',
|
||
stored_fields: ['some', 'fields'],
|
||
})
|
||
----
|
||
|
||
===== Request abortion
|
||
The request abortion API changed
|
||
|
||
All promises returned from the client API calls now have an `abort`
|
||
method that can be used to cancel the request.
|
||
|
||
Before:
|
||
|
||
[source,typescript]
|
||
----
|
||
const controller = new AbortController();
|
||
legacyClient.callAsCurrentUser('ping', {}, {
|
||
signal: controller.signal,
|
||
})
|
||
// later
|
||
controller.abort();
|
||
----
|
||
|
||
After:
|
||
|
||
[source,typescript]
|
||
----
|
||
const request = client.asCurrentUser.ping();
|
||
// later
|
||
request.abort();
|
||
----
|
||
|
||
===== Headers
|
||
It is now possible to override headers when performing specific API
|
||
calls.
|
||
|
||
Note that doing so is strongly discouraged due to potential side effects
|
||
with the ES service internal behavior when scoping as the internal or as
|
||
the current user.
|
||
|
||
[source,typescript]
|
||
----
|
||
const request = client.asCurrentUser.ping({}, {
|
||
headers: {
|
||
authorization: 'foo',
|
||
custom: 'bar',
|
||
}
|
||
});
|
||
----
|
||
|
||
===== Functional tests
|
||
Functional tests are subject to migration to the new client as well.
|
||
|
||
Before:
|
||
|
||
[source,typescript]
|
||
----
|
||
const client = getService('legacyEs');
|
||
----
|
||
|
||
After:
|
||
|
||
[source,typescript]
|
||
----
|
||
const client = getService('es');
|
||
----
|
||
|
||
==== Accessing the client from a route handler
|
||
|
||
Apart from the API format change, accessing the client from within a
|
||
route handler did not change. As it was done for the legacy client, a
|
||
preconfigured <<scoped-services, scoped client>> bound to an incoming request is accessible using
|
||
the `core` context provider:
|
||
|
||
[source,typescript]
|
||
----
|
||
router.get(
|
||
{
|
||
path: '/my-route',
|
||
},
|
||
async (context, req, res) => {
|
||
const { client } = context.core.elasticsearch;
|
||
// call as current user
|
||
const res = await client.asCurrentUser.ping();
|
||
// call as internal user
|
||
const res2 = await client.asInternalUser.search(options);
|
||
return res.ok({ body: 'ok' });
|
||
}
|
||
);
|
||
----
|
||
|
||
==== Accessing the client from a collector's `fetch` method
|
||
|
||
At the moment, the `fetch` method's context receives preconfigured
|
||
<<scoped-services, scoped clients>> for Elasticsearch and SavedObjects.
|
||
To help in the transition, both, the legacy (`callCluster`) and new clients are provided,
|
||
but we strongly discourage using the deprecated legacy ones for any new implementation.
|
||
|
||
[source,typescript]
|
||
----
|
||
usageCollection.makeUsageCollector<MyUsage>({
|
||
type: 'my-collector',
|
||
isReady: async () => true, // Logic to confirm the `fetch` method is ready to be called
|
||
schema: {...},
|
||
async fetch(context) {
|
||
const { callCluster, esClient, soClient } = context;
|
||
|
||
// Before:
|
||
const result = callCluster('search', options)
|
||
|
||
// After:
|
||
const { body: result } = esClient.search(options);
|
||
|
||
return result;
|
||
}
|
||
});
|
||
----
|
||
|
||
Regarding the `soClient`, it is encouraged to use it instead of the plugin's owned SavedObject's repository
|
||
as we used to do in the past.
|
||
|
||
Before:
|
||
|
||
[source,typescript]
|
||
----
|
||
function getUsageCollector(
|
||
usageCollection: UsageCollectionSetup,
|
||
getSavedObjectsRepository: () => ISavedObjectsRepository | undefined
|
||
) {
|
||
usageCollection.makeUsageCollector<MyUsage>({
|
||
type: 'my-collector',
|
||
isReady: () => typeof getSavedObjectsRepository() !== 'undefined',
|
||
schema: {...},
|
||
async fetch() {
|
||
const savedObjectsRepository = getSavedObjectsRepository();
|
||
|
||
const { attributes: result } = await savedObjectsRepository.get('my-so-type', 'my-so-id');
|
||
|
||
return result;
|
||
}
|
||
});
|
||
}
|
||
----
|
||
|
||
After:
|
||
|
||
[source,typescript]
|
||
----
|
||
function getUsageCollector(usageCollection: UsageCollectionSetup) {
|
||
usageCollection.makeUsageCollector<MyUsage>({
|
||
type: 'my-collector',
|
||
isReady: () => true,
|
||
schema: {...},
|
||
async fetch({ soClient }) {
|
||
const { attributes: result } = await soClient.get('my-so-type', 'my-so-id');
|
||
|
||
return result;
|
||
}
|
||
});
|
||
}
|
||
----
|
||
|
||
==== Creating a custom client
|
||
|
||
Note that the `plugins` option is no longer available on the new
|
||
client. As the API is now exhaustive, adding custom endpoints using
|
||
plugins should no longer be necessary.
|
||
|
||
The API to create custom clients did not change much:
|
||
|
||
Before:
|
||
|
||
[source,typescript]
|
||
----
|
||
const customClient = coreStart.elasticsearch.legacy.createClient('my-custom-client', customConfig);
|
||
// do something with the client, such as
|
||
await customClient.callAsInternalUser('ping');
|
||
// custom client are closable
|
||
customClient.close();
|
||
----
|
||
|
||
After:
|
||
|
||
[source,typescript]
|
||
----
|
||
const customClient = coreStart.elasticsearch.createClient('my-custom-client', customConfig);
|
||
// do something with the client, such as
|
||
await customClient.asInternalUser.ping();
|
||
// custom client are closable
|
||
customClient.close();
|
||
----
|
||
|
||
If, for any reasons, you still need to reach an endpoint not listed on
|
||
the client API, using `request.transport` is still possible:
|
||
|
||
[source,typescript]
|
||
----
|
||
const { body } = await client.asCurrentUser.transport.request({
|
||
method: 'get',
|
||
path: '/my-custom-endpoint',
|
||
body: { my: 'payload'},
|
||
querystring: { param: 'foo' }
|
||
})
|
||
----
|