Hooks is a Grafbase Gateway extension type, which allows you to hook into every incoming request before the gateway processes it, and every outgoing response right before you send it back to the client. Hooks help you generate custom headers, modify existing headers, and implement an audit log based on the events that occur during the request processing pipeline.
In comparison to other gateway extension types, a hooks extension is always custom for your gateway deployment.
You implement hooks as Rust functions that compile into a WebAssembly component and an extension manifest file. The Grafbase Gateway engine loads the files. There are two hooks: on-request
, which the system calls immediately after the gateway receives the request, and on-response
, which the system calls immediately before you send the response back to the client.
Start by downloading the Grafbase CLI, Grafbase Gateway, and installing the Rust toolchain. For good developer experience, we recommend installing the rust-analyzer and a good programmer editor, such as Zed, VSCode, NeoVim, or Emacs.
Initialize a new hooks extension with the Grafbase CLI:
grafbase extension init --type hooks my-hooks
The project is structured as follows:
my-hooks/
├── Cargo.toml
├── extension.toml
├── src
│ └── lib.rs
└── tests
└── integration_tests.rs
Cargo.toml
: The project manifest file that defines the project dependencies and configuration.extension.toml
: The extension configuration file that defines the extension metadata, permissions, and static configuration.src/lib.rs
: The main Rust file that implements the hooks.tests/integration_tests.rs
: The integration tests file that tests the hooks.
You can build the extension by running grafbase extension build
, and run the tests by running cargo test
.
The template in src/lib.rs
provides a starting point for implementing hooks. You can modify this file to implement your own hooks.
The simplest possible hooks implementation looks like this:
use grafbase_sdk::{
HooksExtension,
types::{Configuration, Error, ErrorResponse, GatewayHeaders},
host_io::event_queue::EventQueue,
host_io::http::{Method, StatusCode},
};
#[derive(HooksExtension)]
struct MyHooks;
impl HooksExtension for MyHooks {
fn new(config: Configuration) -> Result<Self, Error> {
Ok(Self)
}
fn on_request(&mut self, url: &str, method: Method, headers: &mut GatewayHeaders) -> Result<(), ErrorResponse> {
Ok(())
}
fn on_response(
&mut self,
status: StatusCode,
headers: &mut GatewayHeaders,
event_queue: EventQueue,
) -> Result<(), String> {
Ok(())
}
}
The use block in the beginning imports types and modules we use in the extension:
use grafbase_sdk::{
HooksExtension,
types::{Configuration, Error, ErrorResponse, GatewayHeaders},
host_io::event_queue::EventQueue,
host_io::http::{Method, StatusCode},
};
After the imports, we define a struct MyHooks
with a proc macro to derive the HooksExtension
. The compiler will generate the needed initialization code to the struct that derives the macro attribute.
#[derive(HooksExtension)]
struct MyHooks;
You can keep state in the struct by adding fields to it, and access them from the hook functions through the &mut self
reference. Keep in mind that there will be multiple instances of the struct running in parallel, so each instance will have its own state.
In the next part we initialize the HooksExtension
trait for the MyHooks
struct. A trait is an interface, and by adding the HooksExtension
proc macro attribute to our struct, the compiler expects it to implement the HooksExtension
trait:
impl HooksExtension for MyHooks {
...
}
The extension must implement the following function and methods. The first is new
, which the system calls every time the extension pool reaches capacity and needs to initialize a new instance to serve a request:
fn new(config: Configuration) -> Result<Self, Error> {
Ok(Self)
}
The configuration struct provides access to the extension's configuration. In our case, if you build the extension to /path/to/my-hooks/build
, and our gateway configuration in grafbase.toml
defines the following:
[extensions.my-hooks]
path = "/path/to/my-hooks/build"
[extensions.my-hooks.config]
my_key = "my_value"
We can define a struct to hold the configuration values:
#[derive(HooksExtension)]
struct MyHooks {
config: Config,
}
#[derive(serde::Deserialize)]
struct Config {
my_key: String,
}
impl HooksExtension for MyHooks {
fn new(config: Configuration) -> Result<Self, Error> {
let config = config.deserialize()?;
Ok(Self { config })
}
}
Now the configuration values are available for every subsequent hook calls.
The gateway calls the on_request
method for every incoming request:
fn on_request(
&mut self,
url: &str,
method: Method,
headers: &mut GatewayHeaders
) -> Result<(), ErrorResponse> {
Ok(())
}
Here you get the following arguments:
url
: The URL of the request.method
: The HTTP method of the request.headers
: A mutable reference to the headers of the request.
If the hook returns Ok(())
, the request continues execution. Consider using the Authentication extension for more advanced authentication facilities, and consider the on-request hook for request modification and event sending.
The gateway calls the on_response
hook for every response the gateway sends.
fn on_response(
&mut self,
status: StatusCode,
headers: &mut GatewayHeaders,
event_queue: EventQueue,
) -> Result<(), String> {
Ok(())
}
Here you get the following arguments:
status
: The status code of the response.headers
: A mutable reference to the headers of the response.event_queue
: A queue of events that flow through the request processing.
The response hooks is a good point to modify the response headers if needed, and to store audit logs per request.
The gateway sends a set of standard events to the event queue, which the system creates for every request. You must enable the event types separately in the hooks project extension.toml
file:
[hooks]
events = "*"
The value of events
is a comma-separated list of event types to enable, or a wildcard ("*"
) to enable all event types. The available event types are:
operation
: The system sends an operation event when it executes a GraphQL operation.subgraph_request
: The system sends a subgraph event whenever a subgraph request completes. If the system retries the request, the event holds the response data for every try.http_request
: The system sends an http event whenever the gateway sends an HTTP response.extension
: Any extension can send custom events. The event has a custom name, and serialized data.
You can define your own event type in the hooks extension:
#[derive(serde::Serialize, serde::Deserialize)]
struct CustomEvent {
correlation_id: String,
}
impl HooksExtension for MyHooks {
fn on_request(
&mut self,
url: &str,
method: Method,
headers: &mut GatewayHeaders
) -> Result<(), ErrorResponse> {
let event = CustomEvent {
correlation_id: id_generator::generate_correlation_id(),
};
grafbase_sdk::host_io::event_queue::send("special_event", event)?;
Ok(())
}
}
And then aggregate the events in the on_response
hook:
impl HooksExtension for MyHooks {
fn on_response(
&mut self,
status: StatusCode,
headers: &mut GatewayHeaders,
event_queue: EventQueue,
) -> Result<(), String> {
while let Some(event) = event_queue.pop() {
match event {
Event::Operation(op) => {
// handle operation events
}
Event::Subgraph(req) => {
// handle subgraph events
}
Event::Http(resp) => {
// handle http events
}
Event::Extension(event) => {
if event.event_name() == "special_event" && event.extension_name() == "my-hooks" {
let event: CustomEvent = event.deserialize()
.map_err(|e| format!("Failed to deserialize event: {e}"))?;
// handle the special event from on_response
} else {
// handle or ignore other extension events
}
}
}
}
Ok(())
}
}
The Grafbase SDK crate provides two kinds of loggers: FileLogger
to log custom data to a file with a possible rolling mechanism. Or if using the log crate, you can send log events to the gateway system logger. A file logger is useful if you must have access logs in a separate place outside of the system logs. Create one in the extension initialization and store the instance to the MyHooks
struct:
impl HooksExtension for MyHooks {
fn new(config: Configuration) -> Result<Self, Error> {
let config = config.deserialize()?;
let logger = FileLogger::new("/path/to/access.log", None)?;
Ok(Self { config, logger })
}
}
Then in the on_response
method, you can send any structure deriving serde's Deserialize
trait. The logger provides a convenient log_json
method to store JSON data, but also a log
method to store any serialized data if you prefer storing logs in some other format:
self.logger.log_json(my_data)?;
Alternatively, you can use the log crate to push the logs to the gateway's system logger:
log::info!("Received response: {:?}", my_data);
log::debug!(foo = "bar", kekw = 2; "some message");
You can set the log levels from the gateway log level argument. To enable info logs from the gateway and extensions:
grafbase-gateway --log=info
To enable debug logs from the extensions, info logs from the gateway:
grafbase-gateway --log=extension=debug,info
Keep in mind, if you configure a custom OpenTelemetry exporter in the Grafbase gateway, the system will include the logs passed through the log macros in the data sent to OpenTelemetry.
A typical deployment with the Grafbase Gateway and the hooks extension should contain the following files:
.
├── build
│ ├── extension.wasm
│ └── manifest.json
└── grafbase.toml
The build directory contains the extension.wasm
and manifest.json
files, which the grafbase extension build
command generates. The grafbase.toml
should provide the needed configuration to load the extension:
[extensions.my-hooks]
path = "./build"
[extensions.my-hooks.config]
my_key = "my_value"
You can base your own custom docker image on the Grafbase Gateway Docker image, extending it to build and copy the extension and configuration files.