Customizing the Grafbase Gateway with hooks

You can customize the Grafbase Gateway with dynamically loaded WebAssembly system interface (WASI) components. A selection of languages are available for implementing the guest components. Rust together with the Cargo component subcommand is the recommended tool set, with it being the most robust and easy to use solution for building WASI components.

WASI Preview 2 introduces features unavailable in previous versions, such as network requests and file system access. Note that not every library currently supports network access from WASI. Check the final chapter for tested libraries.

You must deploy the Grafbase Gateway together with the WASM file containing the code for the custom hooks. The first Grafbase Gateway version with support for custom hooks is 0.4.0.

The minimum configuration to enable hooks must define a valid path to the WASM component:

[hooks] location = "path/to/custom.component.wasm"

When starting the Gateway, it will display a log if the component loaded successfully. By default, the WASM component has no IO access enabled. You can enable access gradually with the following boolean options:

  • networking enables network access with TCP and UDP sockets, name resolution and provides WASI HTTP bindings to the guest. Keep in mind the TCP and UDP sockets only work if the guest language provides support to the WASI preview 2 standard.
  • stdout enables the guest to write to the standard output stream.
  • stderr enables the guest to write to the standard error stream.
  • environment_variables copies the all the host environment variables to the guest.

You can enable guest access to the filesystem by defining one or more pre-opened directories:

[[hooks.preopened_directories]] host_path = "/path/in/host/filesystem" guest_path = "/path/in/guest/filesystem" read_permission = true write_permission = true
  • host_path should point to an existing directory in the host filesystem. The user executing the Grafbase Gateway binary must have access to the given directory.
  • guest_path sets the path which is visible in the guest. This path is virtual and can be any value. The guest must use this path when accessing the filesystem.
  • read_permission enables reading all files and directories from the given host_path.
  • write_permission enables creating and modifying files and directories in the given host_path.

Grafbase provides a WebAssembly Interface Type (WIT) file which defines all the interfaces for communication between the host and the guest. The file defines all the types available in the guest, together with the hook function interfaces. You can define the hook functions you want to implement in the world section by exporting the corresponding function interfaces.

There will be more hooks in the future Gateway versions. If you use hooks in your deployment, it's a good idea to read the changelog and adapt to any changes in the WIT definition and guest implementation.

The context object is a key-value store available in all hooks during the request lifetime. You can store information into the context object in the beginning of the request, and read it in every subsequent hook. The values are strings, and if needing more structured data, consider storing it as JSON string.

In the gateway request hook, the storage is mutable and provides the following methods:

  • get fetches a value from the context with the given name, returning none if the value doesn't exist.
  • set stores a value to the context with the given name.
  • delete deletes a value from the context with the given name, returning it if existing.

Hooks called after the gateway request get a shared version of the context, which provides read-only access to key-value storage. Additionally the shared context object provides the following methods:

  • trace-id returns the OpenTelemetry trace id value for the current request on systems enabling OpenTelemetry metrics.
  • log-access stores the given vector of bytes to the system access log file, if enabled.

In the gateway request hook, the request headers are available to read and modify. The interface works similarly to the context methods, but they will return an error if the given header names or values contain characters not allowed in headers.

The following methods are available:

  • get fetches a header value with the given name.
  • set sets a header value with the given name.
  • delete removes a header value with the given name. The method returns the value on successful deletion.
  • entries lists all header key value pairs

You must provide valid strings for header names and values with only ASCII characters. Otherwise the host responds with an error.

  • invalid-header-name: you provided an invalid header name.
  • invalid-header-value: you provided an invalid header value.

The hook can return a GraphQL error. Depending on the hook, the engine either stops processing the request or it adds the error to the request errors and continues processing other parts of the request. The error struct has the following fields:

  • message: custom string message.
  • extensions is a list of tuples. The first part of the tuple is the name of the extension, and the second part the contents of the extension. The content can be a string, and if needing more structured data the value can be JSON encoded as string. If the value is JSON, the engine returns it in the structured form.

If code is provided in the extensions, it will override the default error code set by the gateway.

The engine calls the edge authorization functions with an edge definition, which holds the following data:

  • parent-type-name: is the name of the type the edge is part of.
  • field-name: is the name of the field.

The engine calls the node authorization functions with a node definition, which holds the following data:

  • type-name: is the name of the node's type.

The following hooks are available in the Grafbase Gateway. The engine will call the hook if exporting it in the world section of the WIT file and the guest implements it.

Available in Gateway version 0.4.0.

The gateway-request interface defines a hook function which the engine calls just before authentication in the federated gateway. It gives a mutable access to the context object, and a mutable access to the request headers. By returning an Ok the request execution will continue and by returning an error, the engine ends the execution and the given error returned to the client.

The hook has the following WIT definition:

interface gateway-request { use types.{headers, error, context}; // The hook is called in the federated gateway just before authentication. It can be used // to read and modify the request headers. The context object is provided in a mutable form, // allowing storage for the subsequent hooks to read. // // If returning an error from the hook, the request processing is stopped and the given error // returned to the client. on-gateway-request: func(context: context, headers: headers) -> result<_, error>; }

The subgraph-request interface defines a hook function which the engine calls just before sending the HTTP request to the subgraph allowing header modifications. If an error is returned, the subgraph request won't be executed and will be considered as failed with the provided error. The hook has the following WIT definition:

interface subgraph-request { use types.{shared-context, headers, error}; // The hook is called just before sending the HTTP request to the subgraph. on-subgraph-request: func(context: shared-context, subgraph-name: string, method: string, url: string, headers: headers) -> result<_, error>; }

The authorization interface defines hook functions which are called when fetching data for a node or an edge defining an @authorized directive.

The interface must implement the following hooks has the following WIT definition:

interface authorization { use types.{error, shared-context, edge-definition, node-definition}; // The hook is called in the request cycle if the schema defines an authorization directive on // an edge, providing the arguments of the edge selected in the directive, the definition of the esge // and the metadata of the directive to the hook. // // The hook is run before fetching any data. // // The result, if an error, will stop the request execution and return an error back to the user. // Result of the edge will be null for an error response. authorize-edge-pre-execution: func( context: shared-context, definition: edge-definition, arguments: string, metadata: string ) -> result<_, error>; // The hook is called in the request cycle if the schema defines an authorization directive to // a node, providing the definition of the node and the metadata of the directive to the hook. // // The hook is run before fetching any data. // // The result, if an error, will stop the request execution and return an error back to the user. // Result of the edge will be null for an error response. authorize-node-pre-execution: func( context: shared-context, definition: node-definition, metadata: string ) -> result<_, error>; // Called when `@authorized` is used on a field with `fields` argument: // // type User { // id: ID! // address: Address @authorized(fields: "id") // } // // The engine calls the hook after the subgraph response has arrived with the list of parent fields for // every node containing the address field. authorize-parent-edge-post-execution: func( context: shared-context, definition: edge-definition, parents: list<string>, metadata: string ) -> list<result<_, error>>; // Called when `@authorized` is used on a field with `node` argument: // // type User { // id: ID! // } // // type Query { // users: [User]! @authorized(node: "id") // } // // The engine calls the hook after the subgraph response has arrived with the list of nodes (User here) for the // field. authorize-edge-node-post-execution: func( context: shared-context, definition: edge-definition, nodes: list<string>, metadata: string ) -> list<result<_, error>>; }

The authorize-edge-pre-execution hook

Available in Gateway version 0.4.0.

Called when @authorized is applied on a field with arguments:

type Query { user(id: ID): User @authorized(arguments: "id") }

The engine calls the hook when a query accesses a field with an @authorize directive on the edge level before executing the query.

Engine calls the hook before executing the query with the following:

  • context is the request context object, which can be populated in the on-gateway-request hook.
  • definition defines the edge with its parent type name and the name of the field.
  • arguments is JSON data in string form, with data taken from the query arguments.
  • metadata is static JSON data in string form taken from the directive's metadata argument.

Result of the hook can be one of the following:

  • An empty response allows the request to fetch the data.
  • An error which stops the execution of the request and returns an error back to the user. The requested data will be null.

If the queried edge is returning an optional value, this value will be set null and an error added to the response errors. If the edge is returning a required value, the null gets propagated up to the first nullable edge.

The authorize-parent-edge-post-execution hook

Available in Gateway version 0.7.0.

Called when @authorized is applied on a field with fields:

type User { id: ID! address: Address @authorized(fields: "id") }

The engine will call the hook after the subgraph response has arrived with:

  • context is the request context object, which can be populated in the on-gateway-request hook.
  • definition defines the edge with its parent type name and the name of the field.
  • parents is a list of parent fields, defined by the fields directive argument, serialized in JSON.
  • metadata is static JSON data in string form taken from the directive's metadata argument.

Result is a list of results for each parent. If empty the field is authorized for this particular parent otherwise it's denied and an error is raised and propagated following GraphQL spec instead.

The authorize-edge-node-post-execution hook

Available in Gateway version 0.7.0.

Called when @authorized is applied on a field with node:

type User { id: ID! } type Query { users: [User]! @authorized(node: "id") }

The engine will call the hook after the subgraph response has arrived with:

  • context is the request context object, which can be populated in the on-gateway-request hook.
  • definition defines the edge with its parent type name and the name of the field.
  • nodes is a list of field output nodes with the selected fields defined by the node directive argument, serialized in JSON.
  • metadata is static JSON data in string form taken from the directive's metadata argument.

Result is a list of results for each node. If empty the node is authorized, otherwise it's denied and an error is raised and propagated following GraphQL spec instead.

The authorize-node-pre-execution hook

Available in Gateway version 0.4.0.

The engine calls the hook when a query accesses a type with an @authorize directive on the node level before executing the query.

Engine sends the following data to the hook:

  • context is the request context object, which can be populated in the on-gateway-request hook.
  • definition defines the node with the name of the type.
  • metadata is static JSON data in string form taken from the directive's metadata argument.

Result of the hook can be one of the following:

  • An empty response allows the request to fetch the data.
  • An error which stops the execution of the request and returns an error back to the user. The requested data will be null.

If the hook returns an error, the part of the query which tries to access the given node will return null together with the error defined in the hook. If the field is required, the null value is propagated up to the first optional field in the query.

For the following types:

type User { address: Address secret: Secret } type Address { street: String! } type Secret @authorized { socialSecurityNumber: String! }

A failing authorization for the Secret node will return the following data:

{ "data": { "user" { "address": { "street": "123 Folsom Street" }, "secret: null, } }, "errors": [ { "message": "the message from the hook error response", "path": [ "user", "secret" ], "extensions": { ... } } ] }

If the secret field in the User type is required:

type User { address: Address secret: Secret! } type Address { street: String! } type Secret @authorized { socialSecurityNumber: String! }

A failing authorization for the Secret node will propagate the null value to the first optional parent node:

{ "data": null, "errors": [ { "message": "the message from the hook error response", "path": [ "user", "secret" ], "extensions": { ... } } ] }

Available in Gateway version 0.12.0.

The responses interface defines three hook functions that the engine calls after executing certain steps of its pipeline. These hooks do not get called for subscriptions, only queries and mutations. They provide access to various metrics and shared methods for logging access information. Unlike system logs, access logs allow you to customize what gets logged, when it occurs, and how it formats.

Enable access logs in the Gateway configuration:

[gateway.access_logs] enabled = true path = "/path/to/logs"

Read more on configuration options.

With access logs enabled, invoking the log-access method from the shared context will append the specified bytes to a file called access.log in the configured path.

The response hooks provide access to various points during the request's lifecycle:

interface responses { use types.{shared-context, executed-operation, executed-subgraph-request, executed-http-request, operation}; // The hook is called after a subgraph entity has been either requested or fetched from cache. // The output is a list of bytes, which will be available in the on-operation-response hook. on-subgraph-response: func( context: shared-context, request: executed-subgraph-request, ) -> list<u8>; // The hook is called after an operation is handled in the gateway. The output is a list of bytes, // which will be available in the on-http-response hook. on-operation-response: func( context: shared-context, request: executed-operation, ) -> list<u8>; // The hook is called right before a response is sent to the user. on-http-response: func( context: shared-context, request: executed-http-request, ); }

In all hooks, the shared-context resource gives access to context data stored in the on-gateway-request hook, the log file using the log-access method, and the current trace ID with the trace-id method on systems that enable OpenTelemetry metrics.

The first two hooks can return a set of bytes. The return values of on-subgraph-response hooks appear in the executed-operation record of the on-operation-response hook, and the outputs of on-operation-response hooks are in the executed-http-request record of the on-http-response hook.

Executed subgraph request

This record holds information about an executed subgraph call:

record executed-subgraph-request { // The name of the subgraph. subgraph-name: string, // The request method. method: string, // The subgraph URL. url: string, // The subgraph executions executions: list<subgraph-request-execution-kind>, // The cache status of the subgraph call. cache-status: cache-status, // The time in milliseconds taken for the whole operation. total-duration-ms: u64, // True, if the subgraph returned any errors. has-errors: bool, }

If retries are enabled, executions may contain multiple responses. If data is fetched from the cache, executions will be empty.

The subgraph-request-execution-kind variant holds information about the subgraph request:

variant subgraph-request-execution-kind { // Internal server error in the gateway. internal-server-error, // Response prevented by subgraph request hook. hook-error, // HTTP request failed. request-error, // Request was rate-limited. rate-limited, // A response was received. response(subgraph-response), }

If a request executed successfully, subgraph-response provides metrics on the request:

record subgraph-response { // The milliseconds it took to connect to the host. connection-time-ms: u64, // The milliseconds it took for the host to respond with data. response-time-ms: u64, // The response status code status-code: u16 }

The status code will be 0 for requests that fail before fetching any data.

The cache-status variant indicates caching information:

enum cache-status { // Cache hit hit, // Some data fetched from cache. partial-hit, // Cache miss miss, }

Executed operation

The executed-operation record holds metrics and previous responses from subgraphs:

record executed-operation { // The name of the operation, if present. name: option<string>, // The operation document in sanitized form. document: string, // The time taken in preparing. prepare-duration-ms: u64, // True, if the plan was taken from cache. cached-plan: bool, // Time in milliseconds spent executing the operation. duration: u64, // The status of the operation. status: graphql-response-status, // If queried any subgraphs, the outputs of on-subgraph-response hooks. // Will be empty if no subgraphs were called. on-subgraph-response-outputs: list<list<u8>>, }

The on-subgraph-response-outputs aggregates the return values from all invoked on-subgraph-response hooks.

The GraphQL request status record contains data on success or failure:

variant graphql-response-status { // Request was successful. success, // A field returned an error. field-error(field-error), // A request error. request-error(request-error), // The request was refused. refused-request, } record field-error { // The number of errors. count: u64, // The returned data is null. data-is-null: bool, } record request-error { // The number of errors. count: u64, }

Executed HTTP request

The on-http-response input parameter executed-http-request contains details about the HTTP response:

record executed-http-request { // The request method. method: string, // The request URL. url: string, // The response status code. status-code: u16, // The outputs of executed on-operation-response hooks for every operation of the request. on-operation-response-outputs: list<list<u8>>, }

The on-operation-response-outputs aggregates outputs from all invoked on-operation-response hooks.

The metrics counter grafbase.gateway.access_log.pending increments with each log-access call and decrements once the bytes are written to the access.log. Monitoring this value is crucial. Each log-access call consumes memory until data gets written, and the channel can hold a maximum of 128,000 messages. For the blocking access log method, a full channel will block all log-access calls, while the non-blocking method returns errors, sending data back to the caller.

Rust implementation is so far the easiest due to Rust having the best tools to compile WASI components. This chapter goes through how a guest component implementation works.

You can compile a Wasm component with a recent Rust compiler together with the Cargo component subcommand. The cargo component subcommand provides all the needed tooling for creating and compiling a WASI component. The minimum required version of cargo-component is 0.14.0.

> cargo component new --lib my-hooks

This will create a project structure for a WASI component library called my-hooks with the following project structure:

my-hooks/ ├── Cargo.toml ├── src │   └── lib.rs └── wit └── world.wit

The Cargo.toml file provides the dependencies and build instructions to compile the project:

[package] name = "my-hooks" version = "0.1.0" edition = "2021" license = "MIT" [dependencies] wit-bindgen-rt = { version = "0.26.0", features = ["bitflags"] } [lib] crate-type = ["cdylib"] [profile.release] codegen-units = 1 opt-level = "s" debug = false strip = true lto = true [package.metadata.component] package = "component:my-hooks" [package.metadata.component.dependencies]

First, add the WIT definition from this documentation to the wit/world.wit file.

By building the component, the framework will generate the needed bindings:

> cargo component build

This will return a bunch of errors, which you will fix in the next chapters.

First make sure the to export the gateway-request interface in the wit/world.wit file, in the hooks world:

world hooks { export gateway-request; }

Compile the component to generate bindings. You must edit src/lib.rs to implement the interface:

#[allow(warnings)] mod bindings; // Import the types generated from the WIT file. use bindings::{ component::grafbase::types::{Context, Error, Headers}, exports::component::grafbase::gateway_request, }; // An empty struct which can implement an interface. struct Component; // Implementing the gateway-request interface for the component impl gateway_request::Guest for Component { fn on_gateway_request(context: Context, headers: Headers) -> Result<(), Error> { Ok(()) } } // Export the component to WASI with the given bindings. bindings::export!(Component with_types_in bindings);

The implementation is the simplest possible, returning an empty response and allowing the request to continue.

First make sure the to export the subgraph-request interface in the wit/world.wit file, in the hooks world:

world hooks { export subgraph-request; }

Compile the component to generate bindings. You must edit src/lib.rs to implement the interface:

#[allow(warnings)] mod bindings; // Import the types generated from the WIT file. use bindings::{ component::grafbase::types::{Context, Error, Headers}, exports::component::grafbase::subgraph_request, }; // An empty struct which can implement an interface. struct Component; // Implementing the gateway-request interface for the component impl subgraph_request::Guest for Component { fn on_subgraph_request(context: Context, subgraph_name: String, method: String, url: String, headers: Headers) -> Result<(), Error> { Ok(()) } } // Export the component to WASI with the given bindings. bindings::export!(Component with_types_in bindings);

The implementation is the simplest possible, returning an empty response and allowing the request to continue.

First make sure the to export the authorization interface in the wit/world.wit file, in the hooks world:

world hooks { export authorization; }

Compile the component to generate bindings. You must edit src/lib.rs to implement the interface:

#[allow(warnings)] mod bindings; use bindings::{ component::grafbase::types::{ Context, EdgeDefinition, Error, Headers, NodeDefinition, SharedContext, }, exports::component::grafbase::{authorization, gateway_request}, }; struct Component; impl authorization::Guest for Component { fn authorize_edge_pre_execution( context: SharedContext, definition: EdgeDefinition, arguments: String, metadata: String, ) -> Result<(), Error> { Ok(()) } fn authorize_node_pre_execution( context: SharedContext, definition: NodeDefinition, metadata: String, ) -> Result<(), Error> { Ok(()) } } bindings::export!(Component with_types_in bindings);

The implementation is the simplest possible. Both hooks return an empty Ok value to the engine, and the engine will return the requested data in all cases.

First, export the responses interface in the wit/world.wit file, within the hooks world:

world hooks { export responses; }

Compile the component to generate bindings. Then, modify src/lib.rs to implement the interface:

use bindings::component::grafbase::types::{ CacheStatus, ExecutedHttpRequest, ExecutedOperation, ExecutedSubgraphRequest, Operation, ResponseKind, SharedContext, }; use bindings::exports::component::grafbase::responses::Guest; #[allow(warnings)] mod bindings; struct Component; impl Guest for Component { fn on_subgraph_response(context: SharedContext, request: ExecutedSubgraphRequest) -> Vec<u8> { // Combine a set of bytes from context and subgraph request. } fn on_operation_response( context: SharedContext, operation: Operation, request: ExecutedOperation ) -> Vec<u8> { // Combine a set of bytes from context and operation data. } fn on_http_response(context: SharedContext, request: ExecutedHttpRequest) { let data = Vec::new(); // Combine a set of bytes from context and HTTP request data. // Calling the log_access will write the bytes to the access.log. context.log_access(&data).unwrap(); } } bindings::export!(Component with_types_in bindings);

The serialization of data can vary as long as it returns bytes. As an example, if your access.log contains JSON data, define structured data as Serde structures in the hooks and serialize them into bytes using serde_json.

The log_access method is available in all hooks via shared context. This implementation captures one row of data per request, which the method writes to the log in the on-http-response hook.

See a full example project implementing access logs with Grafbase Gateway.

When done implementing the hooks, we must compile them in release mode. The component can define multiple hooks, and they're all deployment as a single WASI component.

> cargo component build --release

The component is available in target/wasm32-wasip1/release/my_hook.wasm. You must deploy this file with the gateway binary, and you must configure the gateway binary to look for the file in its configuration.

The Go implementation requires more steps and tooling. The tooling isn't yet as robust as the Rust counterparts. You need the following tools for development:

  • wit-bindgen version 0.26.0 or later
  • wasm-tools version 1.211.1 or later
  • TinyGo version 0.32.0 or later
  • Go version 1.21 or later
  • clang compiler and the needed system libraries

Create a new Go project

> mkdir my-hook && cd my-hook > go mod init hooks.com

Copy the WIT definition from this documentation to the project root, name it as grafbase.wit and generate the needed bindings:

> wit-bindgen tiny-go ./grafbase.wit --world hooks --out-dir=gen

Download the wasi_snapshot_preview1.reactor.wasm file from the latest wasmtime release and put it to the root of the project.

You now have the project framework to implement the WASI component.

First make sure to export the gateway-request interface in the grafbase.wit file, in the hooks world:

world hooks { export gateway-request; }

If needed, run the wit-bindgen command as defined in the previous chapter. Create a new file hooks.go with the implementation:

package main import ( . "hooks.com/gen" ) type HooksImpl struct { } func (i HooksImpl) OnGatewayRequest( context ComponentGrafbaseTypesContext, headers ComponentGrafbaseTypesHeaders, ) Result[struct{}, ComponentGrafbaseTypesError] { return Ok[struct{}, ComponentGrafbaseTypesError](struct{}{}) } func init() { hooks := HooksImpl{} SetExportsComponentGrafbaseGatewayRequest(hooks) } func main() {}

The hook is the minimum possible, doing nothing and letting a request to go through.

First make sure to export the authorization in the grafbase.wit file, in the hooks world:

world hooks { export authorization; }

If needed, run the wit-bindgen command as defined in the previous chapter. Create a new file hooks.go with the implementation:

package main import ( . "gateway.com/gen" ) type AuthorizationImpl struct{} func (i AuthorizationImpl) AuthorizeEdgePreExecution( context ComponentGrafbaseTypesSharedContext, definition ComponentGrafbaseTypesEdgeDefinition, arguments string, metadata string, ) Result[struct{}, ComponentGrafbaseTypesError] { return Ok[struct{}, ComponentGrafbaseTypesError](struct{}{}) } func (i AuthorizationImpl) AuthorizeNodePreExecution( context ComponentGrafbaseTypesSharedContext, definition ComponentGrafbaseTypesNodeDefinition, metadata string, ) Result[struct{}, ComponentGrafbaseTypesError] { return Ok[struct{}, ComponentGrafbaseTypesError](struct{}{}) } func init() { hooks := AuthorizationImpl{} SetExportsComponentGrafbaseAuthorization(hooks) } func main() {}

The implementation is the simplest possible. Both hooks return an empty Ok value to the engine, and the engine will return the requested data in all cases.

Next we build the module using TinyGo, componentize it, and adapt it for WASI 0.2:

> tinygo build -o hooks.wasm -target=wasi hooks.go > wasm-tools component embed --world hooks ./grafbase.wit hooks.wasm -o hooks.embed.wasm > wasm-tools component new -o hooks.component.wasm --adapt wasi_snapshot_preview1="wasi_snapshot_preview1.reactor.wasm" hooks.embed.wasm

The component is available in hooks.component.wasm. You must deploy the file with the gateway binary, and you must configure the gateway binary to look for the file in its configuration.

So far the support for networking and HTTP from WASI components is work in progress. WASI support in common libraries might not be available. If needing to trigger HTTP requests from the hooks, please consider writing them in Rust and using a crate which works in the WASI context, such as waki.

WASI support in more popular HTTP libraries such as reqwest or Go's net/http is still work in progress. This documentation will get updated when the situation changes.

Was this page helpful?