Writing Native Rust Plugins
Extend the router with custom Rust code
Your federated graph might have specific requirements that aren't supported by the built-in configuration options of the GraphOS Router or Apollo Router Core. For example, you might need to further customize the behavior of:
Authentication/authorization
Logging
Operation tracing
In these cases, you can create custom plugins for the router.
⚠️ Apollo doesn't recommend creating native plugins for the Apollo Router Core or GraphOS Router, for the following reasons:
Native plugins require familiarity with programming in Rust.
Native plugins require compiling a custom router binary from source, which can introduce unexpected behavior in your router that's difficult to diagnose and support.
Instead, for most router customizations, Apollo recommends creating either a Rhai script or an external coprocessor. Both of these customizations are supported by Apollo and provide strong separation of concerns and fault isolation.
If you must create a native plugin, please open a GitHub issue, and Apollo can investigate adding the custom capability to the stock router binary.
Planning a plugin
When designing a new plugin, you first want to determine which of the router's services the plugin should hook into to achieve its use case.
For descriptions of these services, see Router request lifecycle.
Building a plugin
To demonstrate building a plugin, we'll walk through the hello world example plugin in the router repo.
1. Add modules
Most plugins should start by including the following set of use
declarations:
1use apollo_router::plugin::Plugin;
2use apollo_router::register_plugin;
3use apollo_router::services::*;
4use schemars::JsonSchema;
5use serde::Deserialize;
6use tower::{BoxError, ServiceBuilder, ServiceExt};
When your plugin is complete, the compiler will provide helpful warnings if any of these modules aren't necessary. Your plugin can also use
modules from other crates as needed.
2. Define your configuration
All plugins require an associated configuration. At a minimum, this configuration contains a boolean that indicates whether the plugin is enabled, but it can include anything that can be deserialized by serde
.
Create your configuration struct like so:
1#[derive(Debug, Default, Deserialize, JsonSchema)]
2struct Conf {
3 // Put your plugin configuration here. It's deserialized from YAML automatically.
4}
Note: You need to
derive
JsonSchema
so that your configuration can participate in JSON schema generation.
Then define the plugin itself and specify the configuration as an associated type:
1#[async_trait::async_trait]
2impl Plugin for HelloWorld {
3 type Config = Conf;
4}
3. Implement the Plugin
trait
All router plugins must implement the Plugin
trait. This trait defines lifecycle hooks that enable hooking into a router's services.
The trait also provides a default implementations for each hook, which returns the associated service unmodified.
1// This is a bare-bones plugin that you can duplicate when creating your own.
2use apollo_router::plugin::PluginInit;
3use apollo_router::plugin::Plugin;
4use apollo_router::services::*;
5
6#[async_trait::async_trait]
7impl Plugin for HelloWorld {
8 type Config = Conf;
9
10 // This is invoked once after the router starts and compiled-in
11 // plugins are registered
12 fn new(init: PluginInit<Self::Config>) -> Result<Self, BoxError> {
13 Ok(HelloWorld { configuration: init.config })
14 }
15
16 // Only define the hooks you need to modify. Each default hook
17 // implementation returns its associated service with no changes.
18 fn router_service(
19 &self,
20 service: router::BoxService,
21 ) -> router::BoxService {
22 service
23 }
24
25 fn supergraph_service(
26 &self,
27 service: supergraph::BoxService,
28 ) -> supergraph::BoxService {
29 service
30 }
31
32 fn execution_service(
33 &self,
34 service: execution::BoxService,
35 ) -> execution::BoxService {
36 service
37 }
38
39 // Unlike other hooks, this hook also passes the name of the subgraph
40 // being invoked. That's because this service might invoke *multiple*
41 // subgraphs for a single request, and this is called once for each.
42 fn subgraph_service(
43 &self,
44 name: &str,
45 service: subgraph::BoxService,
46 ) -> subgraph::BoxService {
47 service
48 }
49}
4. Define individual hooks
To define custom logic for a service hook, you can use ServiceBuilder
.
ServiceBuilder
provides common building blocks that remove much of the complexity of writing a plugin. These building blocks are called layers.
1// Replaces the default definition in the example above
2use tower::ServiceBuilderExt;
3use apollo_router::ServiceBuilderExt as ApolloServiceBuilderExt;
4
5fn supergraph_service(
6 &self,
7 service: router::BoxService,
8) -> router::BoxService {
9 // Always use service builder to compose your plugins.
10 // It provides off-the-shelf building blocks for your plugin.
11 ServiceBuilder::new()
12 // Some example service builder methods:
13 // .map_request()
14 // .map_response()
15 // .rate_limit()
16 // .checkpoint()
17 // .timeout()
18 .service(service)
19 .boxed()
20}
The tower-rs library (which the router is built on) comes with many "off-the-shelf" layers. In addition, Apollo provides layers that cover common functionality and integration with third-party products.
Some notable layers are:
buffered - Make a service
Clone
. Typically required for anyasync
layers.checkpoint - Perform a sync call to decide if a request should proceed or not. Useful for validation.
checkpoint_async - Perform an async call to decide if the request should proceed or not. e.g. for Authentication. Requires
buffered
.oneshot_checkpoint_async - Perform an async call to decide if the request should proceed or not. e.g. for Authentication. Does not require
buffered
and should be preferred tocheckpoint_async
for that reason.instrument - Add a tracing span around a service.
map_request - Transform the request before proceeding. e.g. for header manipulation.
map_response - Transform the response before proceeding. e.g. for header manipulation.
Before implementing a layer yourself, always check whether an existing layer implementation might fit your needs. Reusing layers is significantly faster than implementing layers from scratch.
5. Define necessary context
Sometimes you might need to pass custom information between services. For example:
Authentication information obtained by the
SupergraphService
might be required bySubgraphService
s.Cache control headers from
SubgraphService
s might be aggregated and returned to the client by theSupergraphService
.
Whenever the router receives a request, it creates a corresponding context
object and passes it along to each service. This object can store anything that's Serde-compatible (e.g., all simple types or a custom type).
All of your plugin's hooks can interact with the context
object using the following functions:
insert
1context.insert("key1", 1)?;
Adds a value to the context
object. Serialization and deserialization happen automatically. You might sometimes need to specify the type in cases where the Rust compiler can't figure it out by itself.
If multiple threads might write a value to the same context
key, use upsert
instead.
get
1let value : u32 = context.get("key1")?;
Fetches a value from the context
object.
upsert
1context.upsert("key1", |v: u32| v + 1)?;
Use upsert
if you might need to resolve multiple simultaneous writes to a single context
key (this is most likely for the subgraph_service
hook, because it might be called by multiple threads in parallel). Rust is multithreaded, and you will get unexpected results if multiple threads write to context
at the same time. This function prevents issues by guaranteeing that modifications happen serially.
Note: upsert
requires v to implement Default
.
enter_active_request
1let _guard = context.enter_active_request();
2http_client.request().await;
3drop(_guard);
The Router measures how much time it spends working on a request, by subtracting the time spent waiting on network calls, like subgraphs or coprocessors. The result is reported in the apollo_router_processing_time
metric. If the native plugin is performing network calls, then they should be taken into account in this metric. It is done by calling the enter_active_request
method, which returns a guard value. Until that value is dropped, the router will consider that a network request is happening.
6. Register your plugin
To enable the router to discover your plugin, you need to register the plugin.
To do so, use the register_plugin!()
macro provided by apollo-router
. This takes 3 arguments:
A group name
A plugin name
A struct implementing the
Plugin
trait
For example:
1register_plugin!("example", "hello_world", HelloWorld);
Choose a group name that represents your organization and a name that represents your plugin's functionality.
7. Configure your plugin
After you register your plugin, you need to add configuration for it to your YAML configuration file in the plugins:
section:
1plugins:
2 example.hello_world:
3 # Any values here are passed to the plugin as part of your configuration
Using macros
To create custom metrics, traces, and spans, you can use tracing
macros to generate events and logs.
Add custom metrics
To create your custom metrics in Prometheus you can use event macros to generate an event. If you observe a specific naming pattern for your event you'll be able to generate your own custom metrics directly in Prometheus.
To publish a new metric, use tracing macros to generate an event that contains one of the following prefixes:
monotonic_counter.
(non-negative numbers): Used when the metric will only ever increase.
counter.
: For when the metric may increase or decrease over time.
value.
: For discrete data points (i.e., when taking the sum of values does not make semantic sense)
histogram.
: For building histograms (takes f64
)
Examples:
1use tracing::info;
2
3let loading_time = std::time::Instant::now();
4
5info!(monotonic_counter.foo = 1, my_custom_label = "bar"); // Will increment the monotonic counter foo by 1
6// Generated metric for the example above in prometheus
7// foo{my_custom_label="bar"} 1
8info!(monotonic_counter.bar = 1.1);
9
10info!(counter.baz = 1, name = "baz"); // Will increment the counter baz by 1
11info!(counter.baz = -1); // Will decrement the counter baz by 1
12info!(counter.xyz = 1.1);
13
14info!(value.qux = 1);
15info!(value.abc = -1);
16info!(value.def = 1.1);
17
18let caller = "router";
19tracing::info!(
20 histogram.loading_time = loading_time.elapsed().as_secs_f64(),
21 kind = %caller, // Custom attribute for the metrics
22);
Add custom spans
To create custom spans and traces you can use tracing
macros to generate a span.
1use tracing::info_span;
2
3info_span!("my_span");
Plugin Lifecycle
Like individual requests, plugins follow their own strict lifecycle that helps provide structure to the router's execution.
Creation
When the router starts or reloads, it calls new
to create instances of plugins that have configuration in the plugins:
section of the YAML configuration file. If any of these calls fail, the router terminates with helpful error messages.
There is no sequencing for plugin registration, and registrations might even execute in parallel. A plugin should never rely on the existence of another plugin during initialization.
Request and response lifecycle
Within a given service (router, subgraph, etc.), a request is handled in the following order:
Rust plugins, in the same order they're declared in your YAML configuration file.
The corresponding response is handled in the opposite order.
This ordering is relevant for communicating through the context
object.
When a single supergraph request involves multiple subgraph requests, the handling of each subgraph request and response is ordered as above but different subgraph requests may be handled in parallel, making their relative ordering non-deterministic.
Router lifecycle notes
If a router is listening for dynamic changes to its configuration, it also triggers lifecycle events when those changes occur.
Before switching to an updated configuration, the router ensures that the new configuration is valid. This process includes starting up replacement plugins for the new configuration. This means that a plugin should not assume that it's the only executing instance of that plugin in a single router.
After the new configuration is deemed valid, the router shifts to it. The previous configuration is dropped and its corresponding plugins are shut down. Errors during the shutdown of these plugins are logged and do not affect router execution.
Testing plugins
Unit testing of a plugin is typically most helpful and there are extensive examples of plugin testing in the examples and plugins directories.
apollo_router::tracer
module.