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.

The WASI preview 2 enables more features for the guest language -- impossible with the previous preview or normal WebAssembly modules -- such as network requests and file system access. Keep in mind not every library supports network access from WASI in the current versions. See the final chapter for information of libraries tested.

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.

The hooks after the gateway request gets the shared version of the context, which provides only the get method.

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": { ... } } ] }

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.

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?