diff --git a/rfcs/text/0003_handler_interface.md b/rfcs/text/0003_handler_interface.md new file mode 100644 index 00000000000000..51e78cf7c9f547 --- /dev/null +++ b/rfcs/text/0003_handler_interface.md @@ -0,0 +1,356 @@ +- Start Date: 2019-05-11 +- RFC PR: (leave this empty) +- Kibana Issue: (leave this empty) + +# Summary + +Handlers are asynchronous functions registered with core services invoked to +respond to events like a HTTP request, or mounting an application. _Handler +context_ is a pattern that would allow APIs and values to be provided to handler +functions by the service that owns the handler (aka service owner) or other +services that are not necessarily known to the service owner. + +# Basic example + +```js +// services can register context providers to route handlers +http.registerContext('myApi', (context, request) => ({ getId() { return request.params.myApiId } })); + +http.router.route({ + method: 'GET', + path: '/saved_object/:id', + // routeHandler implements the "handler" interface + async routeHandler(context, request) { + // returned value of the context registered above is exposed on the `myApi` key of context + const objectId = context.myApi.getId(); + // core context is always present in the `context.core` key + return context.core.savedObjects.find(objectId); + }, +}); +``` + +# Motivation + +The informal concept of handlers already exists today in HTTP routing, task +management, and the designs of application mounting and alert execution. +Examples: + +```tsx +// Task manager tasks +taskManager.registerTaskDefinitions({ + myTask: { + title: 'The task', + timeout: '5m', + createTaskRunner(context) { + return { + async run() { + const docs = await context.core.elasticsearch.search(); + doSomethingWithDocs(docs); + } + } + } + } +}) + +// Application mount handlers +application.registerApp({ + id: 'myApp', + mount(context, domElement) { + ReactDOM.render( + , + domElement + ); + return () => ReactDOM.unmountComponentAtNode(domElement); + } +}); + +// Alerting +alerting.registerType({ + id: 'myAlert', + async execute(context, params, state) { + const indexPatterns = await context.core.savedObjects.find('indexPattern'); + // use index pattern to search + } +}) +``` + +Without a formal definition, each handler interface varies slightly and +different solutions are developed per handler for managing complexity and +enabling extensibility. + +The official handler context convention seeks to address five key problems: + +1. Different services and plugins should be able to expose functionality that + is configured for the particular context where the handler is invoked, such + as a savedObject client in an alert handler already being configured to use + the appropriate API token. + +2. The service owner of a handler should not need to know about the services + or plugins that extend its handler context, such as the security plugin + providing a currentUser function to an HTTP router handler. + +3. Functionality in a handler should be "fixed" for the life of that + handler's context rather than changing configuration under the hood in + mid-execution. For example, while Elasticsearch clients can technically + be replaced throughout the course of the Kibana process, an HTTP route + handler should be able to depend on their being a consistent client for its + own shorter lifespan. + +4. Plugins should not need to pass down high level service contracts throughout + their business logic just so they can access them within the context of a + handler. + +5. Functionality provided by services should not be arbitrarily used in + unconstrained execution such as in the plugin lifecycle hooks. For example, + it's appropriate for an Elasticsearch client to throw an error if it's used + inside an API route and Elasticsearch isn't available, however it's not + appropriate for a plugin to throw an error in their start function if + Elasticsearch is not available. If the ES client was only made available + within the handler context and not to the plugin's start contract at large, + then this isn't an issue we'll encounter in the first place. + +# Detailed design + +There are two parts to this proposal. The first is the handler interface +itself, and the second is the interface that a service owner implements to make +their handlers extensible. + +## Handler Context + +```ts +interface Context { + core: Record; + [contextName: string]: unknown; +} + +type Handler = (context: Context, ...args: unknown[]) => Promise; +``` + +- `args` in this example is specific to the handler type, for instance in a + http route handler, this would include the incoming request object. +- The context object is marked as `Partial` because the contexts + available will vary depending on which plugins are enabled. +- This type is a convention, not a concrete type. The `core` key should have a + known interface that is declared in the service owner's specific Context type. + +## Registering new contexts + +```ts +type ContextProvider = ( + context: Partial, + ...args: unknown[] +) => Promise; + +interface HandlerService { + registerContext(contextName: T, provider: ContextProvider): void; +} +``` + +- `args` in this example is specific to the handler type, for instance in a http + route handler, this would include the incoming request object. It would not + include the results from the other context providers in order to keep + providers from having dependencies on one another. +- The `HandlerService` is defined as a literal interface in this document, but + in practice this interface is just a guide for the pattern of registering + context values. Certain services may have multiple different types of + handlers, so they may choose not to use the generic name `registerContext` in + favor of something more explicit. + +## Context creation + +Before a handler is executed, each registered context provider will be called +with the given arguments to construct a context object for the handler. Each +provider must return an object of the correct type. The return values of these +providers is merged into a single object where each key of the object is the +name of the context provider and the value is the return value of the provider. +Key facts about context providers: + +- **Context providers are executed in registration order.** Providers are + registered during the setup phase, which happens in topological dependency + order, which will cause the context providers to execute in the same order. + Providers can leverage this property to rely on the context of dependencies to + be present during the execution of its own providers. All context registered + by Core will be present during all plugin context provider executions. +- **Context providers may be executed with the different arguments from + handlers.** Each service owner should define what arguments are available to + context providers, however the context itself should never be an argument (see + point above). +- **Context providers cannot takeover the handler execution.** Context providers + cannot "intercept" handlers and return a different response. This is different + than traditional middleware. It should be noted that throwing an exception + will be bubbled up to the calling code and may prevent the handler from + getting executed at all. How the service owner handles that exception is + service-specific. +- **Values returned by context providers are expected to be valid for the entire + execution scope of the handler.** + +Here's a simple example of how a service owner could construct a context and +execute a handler: + +```js +const contextProviders = new Map()>; + +async function executeHandler(handler, request, toolkit) { + const newContext = {}; + for (const [contextName, provider] of contextProviders.entries()) { + newContext[contextName] = await provider(newContext, request, toolkit); + } + + return handler(context, request, toolkit); +} +``` + +## End to end example + +```js +http.router.registerRequestContext('elasticsearch', async (context, request) => { + const client = await core.elasticsearch.client$.toPromise(); + return client.child({ + headers: { authorization: request.headers.authorization }, + }); +}); + +http.router.route({ + path: '/foo', + async routeHandler(context) { + context.core.elasticsearch.search(); // === callWithRequest(request, 'search') + }, +}); +``` + +## Types + +While services that implement this pattern will not be able to define a static +type, plugins should be able to reopen a type to extend it with whatever context +it provides. This allows the `registerContext` function to be type-safe. +For example, if the HTTP service defined a setup type like this: + +```ts +// http_service.ts +interface RequestContext { + core: { + elasticsearch: ScopedClusterClient; + }; + [contextName: string]?: unknown; +} + +interface HttpSetup { + // ... + + registerRequestContext( + contextName: T, + provider: (context: Partial, request: Request) => RequestContext[T] | Promise + ): void; + + // ... +} +``` + +A consuming plugin could extend the `RequestContext` to be type-safe like this: + +```ts +// my_plugin/server/index.ts +import { RequestContext } from '../../core/server'; + +// The plugin *should* add a new property to the RequestContext interface from +// core to represent whatever type its context provider returns. This will be +// available to any module that imports this type and will ensure that the +// registered context provider returns the expected type. +declare module "../../core/server" { + interface RequestContext { + myPlugin?: { // should be optional because this plugin may be disabled. + getFoo(): string; + } + } +} + +class MyPlugin { + setup(core) { + // This will be type-safe! + core.http.registerRequestContext('myPlugin', (context, request) => ({ + getFoo() { return 'foo!' } + })) + } +}; +``` + +# Drawbacks + +- Since the context properties that are present change if plugins are disabled, + they are all marked as optional properties which makes consuming the context + type awkward. We can expose types at the core and plugin level, but consumers + of those types might need to define which properties are present manually to + match their required plugin dependencies. Example: + ```ts + type RequiredDependencies = 'data' | 'timepicker'; + type OptionalDependencies = 'telemetry'; + type MyPluginContext = Pick & + Pick & + Pick, OptionalDependencies>; + // => { core: {}, data: Data, timepicker: Timepicker, telemetry?: Telemetry }; + ``` + This could even be provided as a generic type: + ```ts + type AvailableContext + = Pick & Required> & Partial>; + type MyPluginContext = AvailableContext; + // => { core: {}, data: Data, timepicker: Timepicker, telemetry?: Telemetry }; + ``` +- Extending types with `declare module` merging is not a typical pattern for + developers and it's not immediately obvious that you need to do this to type + the `registerContext` function. We do already use this pattern with extending + Hapi and EUI though, so it's not completely foreign. +- The longer we wait to implement this, the more refactoring of newer code + we'll need to do to roll this out. +- It's a new formal concept and set of terminology that developers will need to + learn relative to other new platform terminology. +- Handlers are a common pattern for HTTP route handlers, but people don't + necessarily associate similar patterns elsewhere as the same set of problems. +- "Chicken and egg" questions will arise around where context providers should be + registered. For example, does the `http` service invoke its + registerRequestContext for `elasticsearch`, or does the `elasticsearch` service + invoke `http.registerRequestContext`, or does core itself register the + provider so neither service depends directly on the other. +- The existence of plugins that a given plugin does not depend on may leak + through the context object. This becomes a problem if a plugin uses any + context properties provided by a plugin that it does not depend on and that + plugin gets disabled in production. This can be solved by service owners, but + may need to be reimplemented for each one. + +# Alternatives + +The obvious alternative is what we've always done: expose all functionality at +the plugin level and then leave it up to the consumer to build a "context" for +their particular handler. This creates a lot of inconsistency and makes +creating simple but useful handlers more complicated. This can also lead to +subtle but significant bugs as it's unreasonable to assume all developers +understand the important details for constructing a context with plugins they +don't know anything about. + +# Adoption strategy + +The easiest adoption strategy to is to roll this change out in the new platform +before we expose any handlers to plugins, which means there wouldn't be any +breaking change. + +In the event that there's a long delay before this is implemented, its +principles can be rolled out without altering plugin lifecycle arguments so +existing handlers would continue to operate for a timeframe of our choosing. + +# How we teach this + +The handler pattern should be one we officially adopt in our developer +documentation alongside other new platform terminology. + +Core should be updated to follow this pattern once it is rolled out so there +are plenty of examples in the codebase. + +For many developers, the formalization of this interface will not have an +obvious, immediate impact on the code they're writing since the concept is +already widely in use in various forms. + +# Unresolved questions + +Is the term "handler" appropriate and sufficient? I also toyed with the phrase +"contextual handler" to make it a little more distinct of a concept. I'm open +to ideas here.