Docs
Try Apollo Studio

Rhai scripts for the Apollo Router

Extend router functionality without compiling a custom plugin


⚠️ Apollo Router support for Rhai scripting is currently experimental. For details, see experimental limitations.

The Apollo Router provides experimental support for scripts that use the Rhai scripting language. Rhai is useful for performing common script-based tasks (manipulating strings, processing headers, etc.) in a Rust-based project. If you want to learn more about simple programming with Rhai, the book has a language reference which is very helpful.

Rhai scripts hook into the same Apollo Router lifecycle events as native Rust plugins.

Experimental limitations

Like the rest of the Apollo Router, Rhai support is in active development. At this time, functionality is limited.

What you can do:

  • Manipulate request/response HTTP headers
  • Manipulate request/response context
  • Perform checkpoint-style short circuiting of requests
  • Modify the status codes of requests/responses
  • Modify the body of requests (excluding variables)
  • Modify the body of responses

What you can't do (yet):

  • Execute calls to external services

⚠️ As Rhai script is experimental, we may introduce breaking changes to Rhai script functionality after the general availability (GA) release of the Apollo Router. However, any required updates to your existing scripts will probably be minor.

Configuration

plugins:
experimental.rhai:
# Currently there can only be a single rhai file. If there are multiple
# customizations, keep them all in a single file.
filename: "location_of_your_rhai_script.rhai"

Rhai Router Execution Environment

The main source of information about Rhai is the book (see above). That's where to look for general rhai programming questions or advice on how to interact with arrays or manipulate strings or program control flow or (...).

This section covers functionality that the router explicitly exposes to Rhai.

Global State

The execution state of all router rhai scripts contains a constant, apollo_start, which may be used for relative timing operations. (Consider it similar to the Epoch in Unix environments.)

fn router_service(service) {
// Define a closure to process our response
let f = |response| {
let start = apollo_start.elapsed;
// ... Do some processing
let duration = apollo_start.elapsed - start;
print(`response processing took: ${duration}`);
// Log out any errors we may have
print(response.body.errors);
};
// Map our response using our closure
service.map_response(f);
}

Logging

If you print() a message then it will be logged to the router logs at info level. If you want more control over the log level, then there are a series of logging functions:

print("this is a sample message");
log_error("this is error level log message");
log_warn("this is warn level log message");
log_info("this is info level log message");
log_debug("this is debug level log message");
log_trace("this is trace level log message");

Exceptions

If you wish to indicate to the client that an error has occurred, Rhai supports exceptions. Throwing an exception will terminate processing and return an Internal Server Error to the client.

For example:

fn router_service(service) {
// Define a closure to process our response
let f = |response| {
// Something goes wrong during response processing...
throw "an error occurred setting up the router_service...");
};
// Map our response using our closure
service.map_response(f);
}

Service hooks

Similar to native Rust plugins, Rhai scripts can hook into the Apollo Router's four services that handle requests. Just like native Rust plugins, Rhai scripts use a single hook for each service. Like native Rust plugins, the script author can then choose to map requests/response and generally configure the service for different behaviour.

  • router_service
  • query_planner_service
  • execution_service
  • subgraph_service

Each of these hooks is optional. Define only the functions you want to use custom logic for.

Each function takes a single parameter: service, this is typed for each of the different services. The various service functions are not required to return anything. If they do, the return is ignored.

fn router_service(service) {}
fn query_planner_service(service) {}
fn execution_service(service) {}
fn subgraph_service(service) {}

Service Interface

The full functionality of a Rust plugin is not available. The following methods are available for service interactions.

  • map_request
  • map_response

These can be invoked as methods on the supplied service object and are expected to provide a callback function (or closure) which is invoked for actual request or response processing.

For example:

fn router_service(service) {
// Define a closure to process our response
let f = |response| {
// Log out any errors we may have
print(response.body.errors);
};
// Map our response using our closure
service.map_response(f);
}

Request Interface

All requests expose a mechanism for interacting with request Body, Headers, Uri and Context.

request.context
request.headers
request.body.query
request.body.operation_name
request.body.variables
request.body.extensions
request.uri.path

In addition, SubgraphRequest, exposes the additional ability to interact with headers sent to subgraphs:

request.sub_headers

All of the above are read/write apart from request.body.variables which is read-only.

request.context

The context is a key/value store which has a lifespan of router request to router response. Key's must be strings, but values can be any rhai object. See context for more information about contexts.

// You can interact with request.context as an indexed variable
request.context["contextual"] = 42; // inserts a new key in the context "contextual" with value 42
print(`${request.context["contextual"]}`); // writes 42 into the router log at info level
// Rhai also supports extended dot notation for indexed variables so, this is equivalent
request.context.contextual = 42;

request.headers

The headers of a request are accessible as a read/write indexed variable. The keys and values must be valid header name and value strings.

// You can interact with request.headers as an indexed variable
request.headers["x-my-new-header"] = 42.to_string(); // inserts a new header "x-my-new-header" with value "42"
print(`${request.headers["x-my-new-header"]}`); // writes "42" into the router log at info level
// Rhai also supports extended dot notation for indexed variables so, this is equivalent
request.headers.x-my-new-header = 42.to_string();

request.sub_headers

Only present when processing subgraph requests. The interface is exactly the same as for request.header.

// You can interact with request.sub_headers as an indexed variable
request.sub_headers["x-my-new-header"] = 42.to_string(); // inserts a new header "x-my-new-header" with value "42"
print(`${request.sub_headers["x-my-new-header"]}`); // writes "42" into the router log at info level
// Rhai also supports extended dot notation for indexed variables so, this is equivalent
request.sub_headers.x-my-new-header = 42.to_string();

request.body.query

The request query is accessible. If modified make sure to do this before QueryPlanning is performed (i.e.: router_service() or query_planner_service()) or the modification will have no effect on the query. For example, let's modify the query at the router_service stage and turn it into a completely invalid query.

print(`${request.body.query}`); // log the query before modification
request.body.query="query menotvalid { name }}"; // update the query (in this case to an invalid query)
print(`${request.body.query}`); // log the query after modification

request.body.operation_name

If an operation name was defined in the request, then it is accessible. There is a complete example of interacting with the operation name in the examples/op-name-to-header directory.

print(`${request.body.operation_name}`); // log the operation_name before modification
request.body.operation_name +="-my-suffix"; // append "-my-suffix" to the operation_name
print(`${request.body.operation_name}`); // log the operation_name after modification

request.body.variables

Request Variables may be read. They cannot be written (this may change in the future). They are exposed to Rhai as an Object Map.

print(`${request.body.variables}`); // log the variables

request.body.extensions

Request extensions may be read or modified. They are exposed to Rhai as an Object Map.

print(`${request.body.extensions}`); // log the extensions

request.uri.path

Request path may be read or modified. The path is exposed to Rhai as a string and may be set from a string which is a valid Uri Path.

print(`${request.uri.path}`); // log the request path
request.uri.path += "/added-context"; // Add an extra element to the query path

Response Interface

Most responses expose a mechanism for interacting with response Body, Headers and Context. QueryPlannerResponse only exposes a Context, since there are no Body or Headers accessible at that stage.

response.context
response.headers
response.body.label
response.body.data
response.body.errors
response.body.extensions

All of the above are read/write.

Many of these variables are identical in behaviour to their request counterparts: context, headers, body.extensions. In addition, responses contain:

response.body.label

A response may contain a label and this may be read/written as a String.

print(`${response.body.label}`); // logs the response label

response.body.data

A response may contain data (some responses with errors do not contain data). Be careful when manipulating data (and errors) to make sure that response remain valid. data is exposed to Rhai as an Object Map.

There is a complete example of interacting with the response data in the examples/rhai-data-response-mutate directory.

print(`${response.body.data}`); // logs the response data

response.body.errors

A response may contain errors. Errors are represented in rhai as an array of Object Maps.

Each Error must contain at least:

  • a message (String)
  • a location (Array)

(The location can be an empty array.)

Optionally, an error may also contain extensions, which are represented as an Object Map.

There is a complete example of interacting with the response errors in the examples/rhai-error-response-mutate directory.

// Create an error with our message
let error_to_add = #{
message: "this is an added error",
locations: [],
// Extensions are optional, adding some arbitrary extensions to illustrate syntax
extensions: #{
field_1: "field 1",
field_2: "field_2"
}
};
// Add this error to any existing errors
response.body.errors += error_to_add;
print(`${response.body.errors}`); // logs the response errors

Full Examples

Example 1

This example illustrates how to register router request handling.

// At the router_service stage, register callbacks for processing requests
fn router_service(service) {
const request_callback = Fn("process_request"); // This is standard Rhai functionality for creating a function pointer
service.map_request(request_callback); // Register the callback
}
// Generate a log for each request
fn process_request(request) {
log_info("this is info level log message");
}

Example 2

This example manipulates headers and the request context:

// At the router_service stage, register callbacks for processing requests and
// responses.
fn router_service(service) {
const request_callback = Fn("process_request"); // This is standard Rhai functionality for creating a function pointer
service.map_request(request_callback); // Register the request callback
const response_callback = Fn("process_response"); // This is standard Rhai functionality for creating a function pointer
service.map_response(response_callback); // Register the response callback
}
// Ensure the header is present in the request
// If an error is thrown, then the request is short-circuited to an error response
fn process_request(request) {
log_info("processing request"); // This will appear in the router log as an INFO log
// Verify that x-custom-header is present and has the expected value
if request.headers["x-custom-header"] != "CUSTOM_VALUE" {
log_error("Error: you did not provide the right custom header"); // This will appear in the router log as an ERROR log
throw "Error: you did not provide the right custom header"; // This will appear in the errors response and short-circuit the request
}
// Put the header into the context and check the context in the response
request.context["x-custom-header"] = request.headers["x-custom-header"];
}
// Ensure the header is present in the response context
// If an error is thrown, then the response is short-circuited to an error response
fn process_response(response) {
log_info("processing response"); // This will appear in the router log as an INFO log
// Verify that x-custom-header is present and has the expected value
if request.context["x-custom-header"] != "CUSTOM_VALUE" {
log_error("Error: we lost our custom header from our context"); // This will appear in the router log as an ERROR log
throw "Error: we lost our custom header from our context"; // This will appear in the errors response and short-circuit the request
}
}

Example 3

This example converts cookies into headers for transmission to subgraphs. There is a complete working example (with tests) of this in the examples/cookies-to-headers directory.

// Call map_request with our service and pass in a string with the name
// of the function to callback
fn subgraph_service(service, subgraph) {
// Choose how to treat each subgraph using the "subgraph" parameter.
// In this case we are doing the same thing for all subgraphs
// and logging out details for each.
print(`registering request callback for: ${subgraph}`); // print() is the same as using log_info()
const request_callback = Fn("process_request");
service.map_request(request_callback);
}
// This will convert all cookie pairs into headers.
// If you only wish to convert certain cookies, you
// can add logic to modify the processing.
fn process_request(request) {
print("adding cookies as headers");
// Find our cookies
let cookies = request.headers["cookie"].split(';');
for cookie in cookies {
// Split our cookies into name and value
let k_v = cookie.split('=', 2);
if k_v.len() == 2 {
// trim off any whitespace
k_v[0].trim();
k_v[1].trim();
// update our headers
// Note: we must update sub_headers, since we are
// setting a header in our sub graph request
request.sub_headers[k_v[0]] = k_v[1];
}
}
}

There are six complete working examples (with tests) of rhai in the examples directory. The rhai examples are listed in the README.md.

Edit on GitHub
Previous
Custom binary