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

[RFC - Server] Refining the service builder API #1859

Merged
merged 16 commits into from
Nov 3, 2022
Merged

Conversation

LukeMathWalker
Copy link
Contributor

@LukeMathWalker LukeMathWalker commented Oct 17, 2022

Motivation and Context

This PR adds an RFC that explores how we could evolve the new service builder API to improve developer experience, in particular for Rust beginners.

Rendered RFC

The API proposed in this RFC has been manually implemented for the Pokemon service. You can find the code here.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@LukeMathWalker LukeMathWalker requested review from a team as code owners October 17, 2022 07:35
@github-actions
Copy link

A new generated diff is ready to view.

A new doc preview is ready to view.

- com.aws.example#CheckHealth

Use the dedicated methods on `PokemonServiceBuilder` to register the missing handlers:
- PokemonServiceBuilder::check_health
Copy link
Contributor

Choose a reason for hiding this comment

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

Beginners to rust may not know that this is telling them to use builder.check_health(), maybe we should clarify that.

design/src/rfcs/rfc0022_refine_builder.md Show resolved Hide resolved
design/src/rfcs/rfc0022_refine_builder.md Show resolved Hide resolved
Comment on lines +110 to +166
```rust
pub struct PokemonServiceBuilder<
Op1,
Op2,
Op3,
Op4,
Op5,
Op6,
Exts1 = (),
Exts2 = (),
Exts3 = (),
Exts4 = (),
Exts5 = (),
Exts6 = (),
Pl = aws_smithy_http_server::plugin::IdentityPlugin,
> {
check_health: Op1,
do_nothing: Op2,
get_pokemon_species: Op3,
get_server_statistics: Op4,
capture_pokemon: Op5,
get_storage: Op6,
#[allow(unused_parens)]
_exts: std::marker::PhantomData<(Exts1, Exts2, Exts3, Exts4, Exts5, Exts6)>,
plugin: Pl,
}
```

to:

```rust
pub struct PokemonServiceBuilder<
Op1,
Op2,
Op3,
Op4,
Op5,
Op6,
Exts1 = (),
Exts2 = (),
Exts3 = (),
Exts4 = (),
Exts5 = (),
Exts6 = (),
Pl = aws_smithy_http_server::plugin::IdentityPlugin,
> {
check_health: Option<Op1>,
do_nothing: Option<Op2>,
get_pokemon_species: Option<Op3>,
get_server_statistics: Option<Op4>,
capture_pokemon: Option<Op5>,
get_storage: Option<Op6>,
#[allow(unused_parens)]
_exts: std::marker::PhantomData<(Exts1, Exts2, Exts3, Exts4, Exts5, Exts6)>,
plugin: Pl,
}
```
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe a diff view would make the distinction clearer here (tho you lose syntax highlighting AFAIK).

pub struct PokemonServiceBuilder<
    Op1,
    Op2,
    Op3,
    Op4,
    Op5,
    Op6,
    Exts1 = (),
    Exts2 = (),
    Exts3 = (),
    Exts4 = (),
    Exts5 = (),
    Exts6 = (),
    Pl = aws_smithy_http_server::plugin::IdentityPlugin,
  > {
-   check_health: Op1,
+   check_health: Option<Op1>,
    ...
    #[allow(unused_parens)]
    _exts: std::marker::PhantomData<(Exts1, Exts2, Exts3, Exts4, Exts5, Exts6)>,
    plugin: Pl,
}

Copy link
Contributor

@hlbarber hlbarber left a comment

Choose a reason for hiding this comment

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

Looking good so far.

I think we should include a section on what happens to FailOnMissingOperation etc. There are various use cases where we want to opt out of the normal Operation<S, L> upgrade procedure.

design/src/rfcs/rfc0022_refine_builder.md Show resolved Hide resolved
design/src/rfcs/rfc0022_refine_builder.md Show resolved Hide resolved

Trait objects are another common approach to achieve type erasure: instead of storing the raw handler in `PokemonServiceBuilder`, we box it and cast it to a `dyn Upgradable<...>`.
Using trait objects would allow us to delay upgrading to `Route` until `build` is called, mimicking the behaviour of the current API and allowing developers to register plugins at any point before calling `build`.
Using trait objects would unfortunately have a non-zero runtime impact: we would be introducing an extra layer of indirection (double-boxing the handler), affecting the performance of our application at runtime.
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this is the case here - we could Box<Box<Box<dyn Upgradable<...>>>> but the actual upgrade procedure would still result in a singly nested handler (Route::new). So the boxed trait objects here would affect startup but not application runtime.

If we were to type erase via Route::new we would get double-boxed handlers.

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 don't think this is the case here - we could Box<Box<Box<dyn Upgradable<...>>>> but the actual upgrade procedure would still result in a singly nested handler (Route::new)

Is this the case though?

Route::new desugars to a BoxCloneService::new call, which introduces one layer of vtable indirection.
The argument to BoxCloneService::new is the parameter passed to Route::new - if that were to be behind multiple layers of boxing, I am under the impression that we would have to resolve those indirections at runtime when invoking the service.

It's a different situation when we are talking about boxed Plugins - the call to .map would suffer from the indirection, but that only happens at startup time.

Have I got this right or am I missing something (more than likely, tbh)?

Copy link
Contributor

@hlbarber hlbarber Oct 17, 2022

Choose a reason for hiding this comment

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

Currently, Upgradable looks like this:

/// Provides an interface to convert a representation of an operation to a HTTP [`Service`](tower::Service) with
/// canonical associated types.
pub trait Upgradable<Protocol, Operation, Exts, B, Plugin> {
    type Service: Service<http::Request<B>, Response = http::Response<BoxBody>>;

    /// Performs an upgrade from a representation of an operation to a HTTP [`Service`](tower::Service).
    fn upgrade(self, plugin: &Plugin) -> Self::Service;
}

If we wanted to make the type uniform we'd have to remove the associated type which would vary across each operation (else we'd have to specify it in the dyn Upgradable<Service = ....>). So we'd have to move the Route::new into the Ugpradable, like this:

pub trait Upgradable<Protocol, Operation, Exts, B, Plugin> {
    fn upgrade(self, plugin: &Plugin) -> Route<B>;
}

I think is acceptable tweak - all the gnarly Send + Clone + ... bounds would be pushed down into the Upgradable for Operation impl too, rather than hanging around on the service builder which might be worth while anyway (I recall you mentioning this before).

The .boxed() method on the service builder would then take the OpX and turn it into a Box<dyn Upgradable<...>>. The Box here would box the dyn Upgradable and if we call .boxed() multiple times it'd become Box<Box<Box<dyn Upgradable>>> but this is Boxing the actual dyn Upgrade, rather than creating a Box<dyn Service>? The Route::new is still only called once and hence the indirection at runtime only happens once as far as I can tell? The way I'm imagining it is that Box<Box<Box<dyn FnOnce() -> Box<T>>>> when called returns a Box<T> whose access requires 1 indirection rather than 3.

```rust
// Currently you'd have to go for `PluginStack::new(IdentityPlugin, IdentityPlugin)`,
// but that can be smoothed out even if this RFC isn't approved.
let plugin_stack = PluginStack::default()
Copy link
Contributor

@hlbarber hlbarber Oct 17, 2022

Choose a reason for hiding this comment

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

There's an alternative approach we could also take here which gives us some different dynamics.

Currently, because the "upgrade" is performed in the build method, all applied Plugins are used in build. This means that

.get_pokemon_species(/* handler */)
.get_storage(/* handler */)
.print()
.build()

is equivalent to

.get_pokemon_species(/* handler */)
.print()
.get_storage(/* handler */)
.build()

If we are eagerly Route::newing in the setters we could have the current Plugin be used. Which means that the two snippets above would not be equivalent - the latter would not apply the print plugin to the get_storage handler.

This would allow the customer to "scope" plugins in a coarse way - I've been asked if this is the current behavior. I'm not convinced this is a good approach - it might cause confusion.

Copy link
Contributor Author

@LukeMathWalker LukeMathWalker Oct 17, 2022

Choose a reason for hiding this comment

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

I should have called this out in the RFC, but I am indeed not convinced this is the right way forward for the same reason you mentioned - it can be confusing and lead to surprising runtime behaviour.
Especially considering how many existing web frameworks in Rust ignore at what point a middleware is registered and apply it to all routes at the same scope/level.

Copy link
Contributor

Choose a reason for hiding this comment

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

If the intention is that the plugin would only affect the operations registered so far, I think it's ok.

We can also consider providing a way to apply plugins (i.e. layers to the operation before it's HTTP-upgraded) more fine-grained, to specific operations via a method on Operation, instead of the current way in #1837. But this is a one-way door, we can add this functionality later if there's user interest.

Copy link
Contributor

@hlbarber hlbarber Oct 17, 2022

Choose a reason for hiding this comment

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

We can also consider providing a way to apply plugins (i.e. layers to the operation before it's HTTP-upgraded) more fine-grained, to specific operations via a method on Operation, instead of the current way in #1837.

I think just adding a blanket impl

impl<P, Op, Pl, S, L> Pluggable<Pl> for Operation<S, L> where Pl: Plugin<P, Op, S, L> {
    type Output = Operation<Pl::Service, Pl::Layer>;

    fn apply(self, new_plugin: Pl) -> Self::Output {
       new_plugin.map(self)
   }
}

to the aws-smithy-http-server would allow you to

op.print()

where op: Operation.

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 have captured both as an alternative in 90ae7aa
I've added as well a sketch of an API for attaching plugins to groups of routes using a nested/scoped builder.

It supports fine-grained configuration at multiple levels (per-handler middlewares, router middlewares, plugins) while trying to prevent some misconfiguration issues at compile-time (i.e. missing operation handlers).
There is consensus that the new API is an improvement over the pre-existing `OperationRegistryBuilder`/`OperationRegistry`, which is now on its way to deprecation in one of the next releases.

This RFC builds on top of [RFC 20] to explore an alternative API design prior to its stabilisation.
Copy link
Contributor

Choose a reason for hiding this comment

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

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 haven't linked a specific section because this is not technically a "revert" - we had those generic parameters in the OperationRegistryBuilder version as well, so reverting the compile-safety piece does not lead us to a less generic API per se.


### Alternatives: boxed trait objects

Trait objects are another common approach to achieve type erasure: instead of storing the raw handler in `PokemonServiceBuilder`, we box it and cast it to a `dyn Upgradable<...>`.
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps a little example of using the type erasure method e.g. boxed with a conditional branch?

Copy link
Contributor

Choose a reason for hiding this comment

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

You could note a downside here that we cannot type erase MissingOperation etc this way, so the branches would still have the MissingOperation stuck in their signature.

design/src/rfcs/rfc0022_refine_builder.md Outdated Show resolved Hide resolved
design/src/rfcs/rfc0022_refine_builder.md Outdated Show resolved Hide resolved
```rust
impl PokemonService<()> {
/// Constructs a builder for [`PokemonService`].
pub fn builder<Body, Plugin>(plugin: Plugin) -> PokemonServiceBuilder<Body, Plugin> {
Copy link
Contributor

Choose a reason for hiding this comment

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

We could also do a two-phase builder which keeps the user in the method chain, something like:

let app = PokemonService::builder() // : PokemonServicePluginBuilder<IdentityPlugin>
    .print()
    /* ... */
    .finalize_plugins() // PokemonServiceBuilder<B, PluginStack<IdentityPlugin, ..., PrintPlugin>>
    .get_pokemon_species(/* handler */)
    ...
    .build();

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this could work as well, with a PokemonService::builder_without_plugins() for the case where the developer doesn't want to register any plugin and PokemonService::builder().finalize_plugins() would be ugly.

```rust
impl PokemonService<()> {
/// Constructs a builder for [`PokemonService`].
pub fn builder<Body, Plugin>(plugin: Plugin) -> PokemonServiceBuilder<Body, Plugin> {
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 it's worth noting that now Plugin might be a complicated type given that it must be fully specified up front. Do we need to type erase 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.

I do expect that it should be possible to simply have an unconstrained Plugin generic parameter in most functions where you pass the builder around, but we should have a .boxed() method to type-erase the plugin if necessary.
It feels like it won't be very amenable to an impl Trait approach in function signatures.

```rust
// Currently you'd have to go for `PluginStack::new(IdentityPlugin, IdentityPlugin)`,
// but that can be smoothed out even if this RFC isn't approved.
let plugin_stack = PluginStack::default()
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 it'd also be a good idea to make a PluginBuilder here - PluginStack is kinda confusing term to be a primary API.

This would mirror how tower has a ServiceBuilder and Stack.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Captured this in a9aab96 👍🏻

```

This pattern needs to be revisited if we want to move forward with this RFC, since new plugins cannot be registered after the builder has been instantiated.
My recommendation would be to implement `Pluggable` for `PluginStack`, providing the same pattern ahead of the creation of the builder:
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 this is a great bit of luck that this migrates so cleanly. There's no harm in doing this kind of impl even without this RFC being accepted.

Copy link
Contributor

@david-perez david-perez left a comment

Choose a reason for hiding this comment

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

I don't think the missing handler error message is persuasive; there are sharper edges elsewhere. The other two identified shortcomings (branching and partial_setup) are very compelling and I agree we should do something to remediate them.

Let's try to register an operation handler now:

```rust
fn partial_setup<Op1, Op2, Op3, Op4, Op5, Op6>(
Copy link
Contributor

Choose a reason for hiding this comment

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

Far from ideal, but for simple use cases, you could get away with a macro that performs this initialization.

Comment on lines +67 to +75
error[E0277]: the trait bound `MissingOperation: Upgradable<AwsRestJson1, CheckHealth, (), _, IdentityPlugin>` is not satisfied
--> pokemon-service/src/bin/pokemon-service.rs:38:10
|
38 | .build();
| ^^^^^ the trait `Upgradable<AwsRestJson1, CheckHealth, (), _, IdentityPlugin>` is not implemented for `MissingOperation`
|
= help: the following other types implement trait `Upgradable<Protocol, Operation, Exts, B, Plugin>`:
FailOnMissingOperation
Operation<S, L>
Copy link
Contributor

@david-perez david-perez Oct 17, 2022

Choose a reason for hiding this comment

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

I actually think this is one of the nicer error messages you can get. It has enough of the right words MissingOperation, CheckHealth for it to likely to not cause much confusion.

The error messages you get when passing in a handler that does not satisfy the type signature (not async, wrong input / output types) are worse, although those (and perhaps this one too?) can be palliated with a debug macro à la axum's debug_handler, which I think we direly need anyway.

I expect this error to not be frequent. Users will likely start off from a template; the new service builder needs to generate Rust docs with an out-of-the-box compilable program, like in #1498 (comment).

Copy link
Contributor Author

@LukeMathWalker LukeMathWalker Oct 17, 2022

Choose a reason for hiding this comment

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

The error messages you get when passing in a handler that does not satisfy the type signature (not async, wrong input / output types) are worse, although those (and perhaps this one too?) can be palliated with a debug macro à la axum's debug_handler, which I think we direly need anyway.

I don't disagree that those errors are worse, but I don't think that the builder case can be fixed using a debug macro. Or, at least, not in the general case nor easily.
The macro would need to wrap the entire scope where the builder is being used, therefore ruling out refactorings that break the startup logic across multiple functions. We would also have to understand branches in the code we are wrapping to make sure that all paths lead to a correctly instantiated builder - it gets nasty quite quickly.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah it's not feasible.

We could improve the error messages if we named the type params OpUnset, OpSet etc https://github.com/estebank/makeit#error-messages


Trait objects are another common approach to achieve type erasure: instead of storing the raw handler in `PokemonServiceBuilder`, we box it and cast it to a `dyn Upgradable<...>`.
Using trait objects would allow us to delay upgrading to `Route` until `build` is called, mimicking the behaviour of the current API and allowing developers to register plugins at any point before calling `build`.
Using trait objects would unfortunately have a non-zero runtime impact: we would be introducing an extra layer of indirection (double-boxing the handler), affecting the performance of our application at runtime.
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 this is worth exploring. Following a vtable has negligible performance impact for our use cases. If you can flatten the type that the router ends up holding to just a few boxes, it's likely ok.

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've significantly expanded this section exploring two different approaches for lazy type erasure.
I don't believe it's actually practical moving forward with either of them - they do improve slightly on the current status quo but they carry a significant complexity budget in terms of implementation and ongoing maintenance.
We spent some time we @hlbarber to play around with ideas and they get quite complex quite quickly.

@github-actions
Copy link

A new generated diff is ready to view.

A new doc preview is ready to view.

@github-actions
Copy link

A new generated diff is ready to view.

A new doc preview is ready to view.

@github-actions
Copy link

A new generated diff is ready to view.

A new doc preview is ready to view.

@github-actions
Copy link

A new generated diff is ready to view.

A new doc preview is ready to view.

@github-actions
Copy link

A new generated diff is ready to view.

A new doc preview is ready to view.

@github-actions
Copy link

A new generated diff is ready to view.

A new doc preview is ready to view.

@github-actions
Copy link

A new generated diff is ready to view.

A new doc preview is ready to view.

Having to specify all plugins upfront is unlikely to have a negative impact on developers currently using `smithy-rs`.
We have seen how cumbersome it is to break the startup logic into different functions using the current service builder API. Developers are most likely specifying all plugins and routes in the same function even if the current API allows them to intersperse route registrations and plugin registrations: they would simply have to re-order their registration statements to adopt the API proposed in this RFC.

### Alternatives: allow new plugins to be registered after builder creation
Copy link
Contributor

@hlbarber hlbarber Oct 24, 2022

Choose a reason for hiding this comment

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

I think we should add an additional alternative here:

  • Don't solve the function signature problem at all, the customer just has to PokemonService::builder()....build() within one function.
  • Solve the conditional branch problem with Either<A, B> types (e.g. A: Upgradable<...> and B: Upgradable<...> implies Either<A, B>: Upgradable.

Copy link
Contributor

Choose a reason for hiding this comment

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

Don't solve the function signature problem at all, the customer just has to PokemonService::builder()....build() within one function.

I think the RFC should mention using macros instead of functions to workaround this limitation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Macros that we provide or macros written on an ad-hoc basis by the customer?

Copy link
Contributor

Choose a reason for hiding this comment

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

Written by the user. The problem here is the user having to type the complicated function signature that does partial initialization. If they just plop the function body into a macro, they don't have to.

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'd argue that writing a macro is a skill outside the comfort zone of most Rust users, who primarily consume them. Sketching a very simple macro_rules! is somewhat more accessible, but not much.

Copy link
Contributor

@hlbarber hlbarber left a comment

Choose a reason for hiding this comment

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

The problems described here are very real. The solution space is very subtle and the RFC is a great exploration of it. Here's a summary of my thoughts:

Clear wins in this exploration are:

We should implement these regardless.

The main proposal provides the customer with a super ergonomic solution to both the conditional branching and error message problem. This is given to the customer by default - no intervention is required on their part.

The function signature problem seems more difficult to solve generally. The main proposal prevents the customer from having to work with the O(n) type parameters but the Plugin itself is now required to be known up front. The customer now only has to deal with 1 nasty type parameter when passing a builder through a function signature, but is it still too much of an obstruction? With that said, alternative approach 1 and 2 are strictly less ergonomic and the Either proposal doesn't even attempt to solve it.

Fundamentally, if we want Plugin<P, Op, S, L> to be applied to operations we cannot do it after type erasure of P, Op, S, L. Any solution which preserves the current Plugin architecture and type erases any of these parameters will prevent Plugins being applied afterward. You can see this with the main proposal and the alternative approach 2.

The only feasible solution which solves the conditional branching problem, provides an infallible builder, and prevents eager Plugin is to provide Either<A, B>: Upgradable in my opinion. This does not solve the function signature problem or the error problem and we're still stuck with O(n) type parameters.

In conclusion:

  • Both lazy type erasure via Box<dyn Upgradable> and Route::new can be used to hold onto the compile time safety of build but are far worse ergonomics for the customer and still require Plugins to be known up front - this probably not a good trade-off.
  • If we submit to the inevitability of the function signature problem then I think the Either proposal is the primary contestant to the main proposal.

Copy link
Contributor

@david-perez david-perez left a comment

Choose a reason for hiding this comment

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

What's the plan for solving the same set of problems when building a plugin stack (conditionally applying a plugin and partial initialization of a plugin stack in a function)?

This constraint guarantees that all operation handlers are upgraded to a `Route` using the same set of plugins.

Having to specify all plugins upfront is unlikely to have a negative impact on developers currently using `smithy-rs`.
We have seen how cumbersome it is to break the startup logic into different functions using the current service builder API. Developers are most likely specifying all plugins and routes in the same function even if the current API allows them to intersperse route registrations and plugin registrations: they would simply have to re-order their registration statements to adopt the API proposed in this RFC.
Copy link
Contributor

Choose a reason for hiding this comment

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

Developers are most likely specifying all plugins and routes in the same function

This seems to go counter to the Refactoring into smaller functions -> Prepare for some type juggling! section that motivates the proposal for reducing type parameters.

Copy link
Contributor Author

@LukeMathWalker LukeMathWalker Oct 24, 2022

Choose a reason for hiding this comment

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

I should have worded this better.
What I meant: since it's so complicated to refactor into smaller functions (given the problems shown in the sections you linked) we can be fairly confident that all the code in the wild is currently sticking to having everything in a single function to avoid the pain (even if they don't necessarily want to).

@LukeMathWalker
Copy link
Contributor Author

Based on our standup discussion from a few days ago:

  • This RFC does not attempt to solve the same set of problems (branching, refactoring into functions) for building a Plugin stack. We should explore the ergonomics of Plugin stack building in a separate RFC and/or provide dedicated documentation for common patterns that we expect developers to use;
  • We will move forward with the implementation of this RFC before releasing the new builder API to minimise disruption (e.g. breaking changes) for users.

I'll merge this in and get cracking.

@LukeMathWalker LukeMathWalker enabled auto-merge (squash) November 3, 2022 16:47
@github-actions
Copy link

github-actions bot commented Nov 3, 2022

A new generated diff is ready to view.

A new doc preview is ready to view.

@LukeMathWalker LukeMathWalker merged commit 11e9a5f into main Nov 3, 2022
@LukeMathWalker LukeMathWalker deleted the refine-builder branch November 3, 2022 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants