-
Notifications
You must be signed in to change notification settings - Fork 8.2k
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
Expose whitelisted config values to client-side plugin #50641
Conversation
Pinging @elastic/kibana-platform (Team:Platform) |
const uiPlugins = this.pluginsSystem.uiPlugins(); | ||
return { | ||
contracts: await this.pluginsSystem.setupPlugins(deps), | ||
uiPlugins: this.pluginsSystem.uiPlugins(), | ||
contracts, | ||
uiPlugins: { | ||
...uiPlugins, | ||
config: this.generateUiPluginsConfigs(uiPlugins.public), | ||
}, |
There was a problem hiding this comment.
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
private generateUiPluginsConfigs( | ||
uiPlugins: Map<string, DiscoveredPlugin> | ||
): Map<PluginName, Observable<unknown>> { |
There was a problem hiding this comment.
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.
src/core/server/plugins/types.ts
Outdated
export interface PluginConfigDescriptor<T = any> { | ||
exposeToBrowser?: Array<keyof T>; | ||
schema: PluginConfigSchema<T>; | ||
} |
There was a problem hiding this comment.
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
src/plugins/testbed/public/plugin.ts
Outdated
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}'`); |
There was a problem hiding this comment.
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.
💚 Build Succeeded |
), | ||
]; | ||
} | ||
return [pluginId, of({})]; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 || [])) |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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 }
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
.
💚 Build Succeeded |
This comment has been minimized.
This comment has been minimized.
💔 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 || {}) |
There was a problem hiding this comment.
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 = {} }) => {
src/core/server/plugins/types.ts
Outdated
/** | ||
* List of configuration properties that will be available on the client-side plugin. | ||
*/ | ||
exposeToBrowser?: Array<keyof T>; |
There was a problem hiding this comment.
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,
};
There was a problem hiding this comment.
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.
configDescriptor && | ||
configDescriptor.exposeToBrowser && | ||
configDescriptor.exposeToBrowser.length > 0 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
💚 Build Succeeded |
type ConfigType = TypeOf<typeof configSchema>; | ||
|
||
export const config: PluginConfigDescriptor<ConfigType> = { | ||
exposeToBrowser: ['uiProp'], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
outdated docs?
There was a problem hiding this comment.
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>() { |
There was a problem hiding this comment.
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
src/core/server/plugins/types.ts
Outdated
* | ||
* @public | ||
*/ | ||
export type PluginConfigSchema<T = unknown> = Type<T>; |
There was a problem hiding this comment.
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
This comment has been minimized.
This comment has been minimized.
💚 Build Succeeded |
* 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
* 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 since this is a new feature, can we switch the release note to dev docs and add the relevant documentation? |
@eliperelman Indeed we should. Just added the |
💚 Build Succeeded |
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
The values whitelisting follows the consensus in #41990:
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
strikethroughsto 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- [ ] This was checked for keyboard-only and screenreader accessibilityFor 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 appropriatelyDev 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
And can then be accessed in the client-side plugin using the
PluginInitializerContext
: