Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose whitelisted config values to client-side plugin #50641

Merged
merged 20 commits into from
Nov 21, 2019

Conversation

pgayvallet
Copy link
Contributor

@pgayvallet pgayvallet commented Nov 14, 2019

Summary

Fixes #41990

PR adds a mechanism to share some explicitly whitelisted configuration keys to the client part of the plugin.
The configuration access in the client follow the API already implemented server-side, using something like

const config = this.initializerContext.config.get<ConfigType>();

The values whitelisting follows the consensus in #41990:

const configSchema = schema.object({
  secret: schema.string({ defaultValue: 'Not really a secret :/' }),
  uiProp: schema.string({ defaultValue: 'Accessible from client' }),
});

type ConfigType = TypeOf<typeof configSchema>;

export const config: PluginConfigDescriptor<ConfigType> = {
  exposeToBrowser: {
    uiProp: true,
  },
  schema: configSchema,
};

The actual server->client sharing is based on the injectedMetadata, as we currently don't have a proper 'NP' way to share things between the two world.

Checklist

Use strikethroughs to remove checklist items you don't feel are applicable to this PR.

- [ ] This was checked for cross-browser compatibility, including a check against IE11
- [ ] Any text added follows EUI's writing guidelines, uses sentence case text and includes i18n support

For maintainers

- [ ] This was checked for breaking API changes and was labeled appropriately
- [ ] This includes a feature addition or change that requires a release note and was labeled appropriately

Dev Docs

New Platform plugins with both a server and client parts can now expose configuration properties to the client-side plugin.

The properties to expose must be whitelisted in the config declaration

// my_plugin/server/index.ts
const configSchema = schema.object({
  secret: schema.string({ defaultValue: 'Not really a secret :/' }),
  uiProp: schema.string({ defaultValue: 'Accessible from client' }),
});

type ConfigType = TypeOf<typeof configSchema>;

export const config: PluginConfigDescriptor<ConfigType> = {
  exposeToBrowser: {
    uiProp: true,
  },
  schema: configSchema,
};

And can then be accessed in the client-side plugin using the PluginInitializerContext:

// my_plugin/public/index.ts
interface ClientConfigType {
  uiProp: string;
}
export class Plugin implements Plugin<PluginSetup, PluginStart> {
  constructor(private readonly initializerContext: PluginInitializerContext) {}
  public async setup(core: CoreSetup, deps: {}) {
    const config = this.initializerContext.config.get<ClientConfigType>();
    // ...
  }

@pgayvallet pgayvallet added Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc Feature:New Platform v8.0.0 release_note:skip Skip the PR/issue when compiling release notes v7.6.0 labels Nov 14, 2019
@elasticmachine
Copy link
Contributor

Pinging @elastic/kibana-platform (Team:Platform)

@pgayvallet pgayvallet changed the title Expose whitelisted config values to frontend Expose whitelisted config values to client-side plugin Nov 14, 2019
Comment on lines 103 to 109
const uiPlugins = this.pluginsSystem.uiPlugins();
return {
contracts: await this.pluginsSystem.setupPlugins(deps),
uiPlugins: this.pluginsSystem.uiPlugins(),
contracts,
uiPlugins: {
...uiPlugins,
config: this.generateUiPluginsConfigs(uiPlugins.public),
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uiPlugins are directly serialized in injected metadatas, so I added the configuration in a distinct property instead of trying to merge in the uiPlugins structs

Comment on lines +124 to +126
private generateUiPluginsConfigs(
uiPlugins: Map<string, DiscoveredPlugin>
): Map<PluginName, Observable<unknown>> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observable is currently not of use, as we take(1) before serializing to InjectedMetadata, however this is futur-proof for when we'll want to implement dynamic client-side config refresh as we do on server side.

Comment on lines 29 to 32
export interface PluginConfigDescriptor<T = any> {
exposeToBrowser?: Array<keyof T>;
schema: PluginConfigSchema<T>;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The <T =any> here instead of unknow allows the plugins to use it either with or without specifying the type

export const config: PluginConfigDescriptor = {
  exposeToBrowser: ['uiProp'],
  schema: configSchema,
};

export const config: PluginConfigDescriptor<ConfigType> = {
  exposeToBrowser: ['uiProp'],
  schema: configSchema,
};

with default unknown type, the exposeToBrowser?: Array<keyof T>; will be an Array<never>, forcing to actually specify the type when declaring the PluginConfigDescriptor

Comment on lines 27 to 37
export class TestbedPlugin implements Plugin<TestbedPluginSetup, TestbedPluginStart> {
public setup(core: CoreSetup, deps: {}) {
constructor(private readonly initializerContext: PluginInitializerContext) {}

public async setup(core: CoreSetup, deps: {}) {
const config = await this.initializerContext.config
.create<ConfigType>()
.pipe(take(1))
.toPromise();

// eslint-disable-next-line no-console
console.log(`Testbed plugin set up`);
console.log(`Testbed plugin set up. uiProp: '${config.uiProp}'`);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added an example usage in the testbed plugin. The commit is isolated, tell me if I should remove it.

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

),
];
}
return [pluginId, of({})];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this necessary with your check here? daf09e3#diff-ce5eebb41b6315c38446e8a68a4ca0aeR242

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was filtering null values after the map call using [...uiPlugins].map([...]).filter, but it seems typescript doesn't properly recognize Observable<unknown> | null filtered with !== null as Observable<unknown>. So the only purpose of this is to avoid having the method return type to Map<PluginName, Observable<unknown> | null>. But this can be changed

this.configService
.atPath(plugin.configPath)
.pipe(
map((config: any) => pick(config || {}, configDescriptor.exposeToBrowser || []))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: exposeToBrowser cannot be falsy. daf09e3#diff-725c498a267472754f01cff2baa3901bR130

contracts,
uiPlugins: {
...uiPlugins,
config: this.generateUiPluginsConfigs(uiPlugins.public),
Copy link
Contributor

@mshustov mshustov Nov 18, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: The name collision probability is minimal. But to simplify reading, shouldn't we handle it separately?

{ contracts, uiPlugins, uiConfig }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, wasn't sure which one was the best approach. Both works with me, I can change it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a87b7b0 separates configs from plugins

): PluginInitializerContext {
return {
opaqueId,
env: coreContext.env,
config: {
create<T>() {
return of<T>((pluginConfig as unknown) as T);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we going to support runtime config updates on the client-side? Client-side plugins have a shorter time of life comparing to server-side ones. Should we provide a sync getter interface to simplify consumers' logic?

Copy link
Contributor Author

@pgayvallet pgayvallet Nov 18, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an excellent question.

There are two reasons for this:

  • Somewhere in the initial ticket I think I read that as NP was going to be SPA, the actual client app reload frequency was going to drastically decrease, and that we might want to support client-side runtime updates at some point, so this futur-proofs the API.
  • It's consistent with the server counterpart.

However currently, this returns an 'sync' of() observable, so a sync getter can easily be added or even replace the #create function. Drawback is that if at some point we actually implements client config reload, we'll have to remove this sync getter.

Not sure how realistic implementing the client config reload is in short/mid term, so I don't know what is best here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhere in the initial ticket I think I read that as NP was going to be SPA, the actual client app reload frequency was going to drastically decrease, and that we might want to support client-side runtime updates at some point, so this futur-proofs the API.

I'm not opposed to the idea. Albeit I see it is as a reasonable compromise, that platform sacrifice this functionality for the sake of plugin API simplicity. The platform can support config updates requesting a user to reload a page. We already do this for uiSettings.
https://github.com/elastic/kibana/blob/master/src/core/server/ui_settings/types.ts#L102
It reminds us that not all the parameters can be updated run time.
The main use case for ui config is myplugin.ui.enabled. It's can be updated in runtime as long as plugins use it with stateless API, e.g. to render content conditionally in React. Things get complicated when it's used with stateful API, like register a section on the management page or application registration.

Also, we can provide a sync config getter now and expose config stream when and if needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, sounds good to me. Will change for sync getter.

import { take } from 'rxjs/operators';
import { Plugin, CoreSetup, PluginInitializerContext } from 'kibana/public';

interface ConfigType {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 for simplicity.
optional: We can add a test that server side types can be reused.
server/config.ts

import { PluginConfigDescriptor } from 'kibana/server';

import { schema, TypeOf } from '@kbn/config-schema';

const configSchema = schema.object({
  secret: schema.string({ defaultValue: 'Not really a secret :/' }),
  uiProp: schema.string({ defaultValue: 'Accessible from client' }),
});

export const config: PluginConfigDescriptor<ConfigType> = {
  exposeToBrowser: ['uiProp'],
  schema: configSchema,
};

export type ConfigType = TypeOf<typeof configSchema>;
export type PublicConfigType = Pick<ConfigType, 'uiProp'>;

server/index.ts

import { config, ConfigType } from './config';
export { config };

public/plugin.ts

import { PublicConfigType } from '../server/config'; // or re-export it from /server/types.ts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great and ensure the PublicConfigType is correctly typed. Only question I have is regarding server-to-client imports: from what I understood, we needs to be extra careful on these imports to avoid polluting the client bundles with imports dependencies, so what should be the recommended way to do this? creates a types.ts files at the server plugin root folder level?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

creates a types.ts files at the server plugin root folder level?

sounds reasonable. @kbn/config-schema is used in node env., so we cannot recommend declaring types under public or common.

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

@elasticmachine

This comment has been minimized.

@elasticmachine
Copy link
Contributor

💔 Build Failed

@@ -76,7 +74,7 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
new PluginWrapper(
plugin,
opaqueIds.get(id)!,
createPluginInitializerContext(this.coreContext, opaqueIds.get(id)!, plugin)
createPluginInitializerContext(this.coreContext, opaqueIds.get(id)!, plugin, config || {})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: use a default in the object destructure on line 64 instead:

plugins.forEach(({ id, plugin, config = {} }) => {

/**
* List of configuration properties that will be available on the client-side plugin.
*/
exposeToBrowser?: Array<keyof T>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though we're only supporting top-level keys at this time, I think it would be more future proof if we type this as an object of string-boolean pairs. This could be extended in the future to support nested keys.

exposeToBrowser?: { [P in keyof T]?: boolean };

Plugins would then specify this like:

export const config: PluginConfigDescriptor<ConfigType> = {
  exposeToBrowser: {
    uiProp: true,
  },
  schema: configSchema,
};

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify, I think { [P in keyof T]?: boolean } is a better type to use now over Array<keyof T> because it is compatible with a type that we could use in the future for nested keys:

type BooleanValues<T> = {
  [P in keyof T]?: T[P] extends object ? boolean | BooleanValues<T> : boolean
}

By using a compatible type now, we can easily add nested key support in the future without breaking all existing exposeToBrowser configurations.

Comment on lines 130 to 132
configDescriptor &&
configDescriptor.exposeToBrowser &&
configDescriptor.exposeToBrowser.length > 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we upgraded to TypeScript 3.7, I think you can use optional chaining here:

configDescriptor?.exposedToBrowser?.length > 0

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to, but the ?? operator was not properly reconized, both by eslint and script/typecheck, so I went back to the 2000 approach

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

type ConfigType = TypeOf<typeof configSchema>;

export const config: PluginConfigDescriptor<ConfigType> = {
exposeToBrowser: ['uiProp'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outdated docs?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, outdated tsdoc, thanks

): PluginInitializerContext {
return {
opaqueId,
env: coreContext.env,
config: {
get<T>() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T extends object? at least to outline the default value and requirements to the generic type

*
* @public
*/
export type PluginConfigSchema<T = unknown> = Type<T>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think = unknown is default value

@elasticmachine

This comment has been minimized.

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

@pgayvallet pgayvallet merged commit 1bef133 into elastic:master Nov 21, 2019
pgayvallet added a commit to pgayvallet/kibana that referenced this pull request Nov 21, 2019
* introduce PluginConfigDescriptor type

* inject client plugin configs in injectedMetadata

* expose client config in PluginInitializerContext

* add example implementation in testbed

* update generated doc

* only generates ui config entry for plugins exposing properties to client

* separate plugin configs from plugins

* restructure plugin services tests

* fix test/mocks due to plugin configs api changes

* add unit tests

* update migration guide

* update tsdoc

* fix typecheck

* use sync getter for config on client side instead of observable

* change type of exposeToBrowser prop

* updates generated doc

* fix doc and address nits
pgayvallet added a commit that referenced this pull request Nov 21, 2019
* introduce PluginConfigDescriptor type

* inject client plugin configs in injectedMetadata

* expose client config in PluginInitializerContext

* add example implementation in testbed

* update generated doc

* only generates ui config entry for plugins exposing properties to client

* separate plugin configs from plugins

* restructure plugin services tests

* fix test/mocks due to plugin configs api changes

* add unit tests

* update migration guide

* update tsdoc

* fix typecheck

* use sync getter for config on client side instead of observable

* change type of exposeToBrowser prop

* updates generated doc

* fix doc and address nits
@eliperelman
Copy link
Contributor

@pgayvallet since this is a new feature, can we switch the release note to dev docs and add the relevant documentation?

@pgayvallet pgayvallet added release_note:plugin_api_changes Contains a Plugin API changes section for the breaking plugin API changes section. and removed release_note:skip Skip the PR/issue when compiling release notes labels Nov 24, 2019
@pgayvallet
Copy link
Contributor Author

@eliperelman Indeed we should. Just added the Dev Docs section. Do you mind taking a quick look to check if that seems ok?

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature:New Platform release_note:plugin_api_changes Contains a Plugin API changes section for the breaking plugin API changes section. Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v7.6.0 v8.0.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add config value support in frontend to New Platform
5 participants