grafilter 0.0.1

A GraphQL proxy library with field-level interception, conditional forwarding, and response transformation
Documentation
  • Coverage
  • 8.7%
    2 out of 23 items documented0 out of 16 items with examples
  • Size
  • Source code size: 41.68 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 3.99 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 54s Average build duration of successful builds.
  • all releases: 54s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • Repository
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • reu

grafilter

A Rust library for building GraphQL proxies with field-level interception. Sits between your client and an upstream GraphQL server, letting you intercept, transform, or fully resolve individual fields — all through a single unified API.

Why?

Existing GraphQL middleware libraries either wrap local resolvers (not proxies) or operate at the schema level (rename types, filter fields). None of them give you a single closure where you decide at runtime whether to call upstream, resolve locally, or transform the response.

grafilter fills that gap. One intercept call, one closure, full control:

  • Resolve locally — return a value without ever hitting upstream
  • Forward and transform — call upstream, then modify the result
  • Conditional forwarding — check a cache first, only call upstream on miss
  • Access parent fields — read sibling fields from the parent object
  • Per-request state — inject request-scoped data (tenant ID, auth, etc.) accessible in every interceptor

Quick start

use grafilter::{Grafilter, Executor, Error};
use serde_json::{json, Value};
use std::sync::Arc;

// You provide the executor — any async function that sends a query
// string to your upstream GraphQL server and returns the JSON response.
let executor: Executor = Arc::new(|query: String| {
    Box::pin(async move {
        // your HTTP client call here
        todo!()
    })
});

let proxy = Grafilter::builder(executor)
    .intercept(("user", "email"), |ctx| async move {
        // Mask the email before returning to the client
        let original = ctx.forward().await?;
        let masked = original.as_str().unwrap_or("").replace("@", " [at] ");
        Ok(json!(masked))
    })
    .build();

let result = proxy
    .execute("{ user { name email } }", &json!({}), ())
    .await?;

Schema-aware type matching

Without a schema, interceptors match by parent field name: ("user", "email") intercepts email under the user field. This works but breaks if the same type appears under different field names (e.g., author is also a User).

With a schema, interceptors match by actual GraphQL type name:

// Fetch the introspection result from your upstream server
let schema_json = my_client
    .post(url, grafilter::introspection_query())
    .await?;

let proxy = Grafilter::builder(executor)
    .with_schema(&schema_json)?
    // Matches email on ANY User, whether it's under "user", "author",
    // "assignee", etc.
    .intercept(("User", "email"), |_ctx| async move {
        Ok(json!("***@***.com"))
    })
    .build();

Per-request state

Similar to Axum's .with_state(), you can pass request-scoped state that every interceptor can access:

#[derive(Clone)]
struct RequestCtx {
    tenant_id: String,
    user_role: String,
}

let proxy = Grafilter::<RequestCtx>::builder(executor)
    .intercept(("User", "email"), |ctx| async move {
        if ctx.state().user_role == "admin" {
            // Admins see the real email
            ctx.forward().await
        } else {
            Ok(json!("hidden"))
        }
    })
    .build();

// State is passed per-request
let ctx = RequestCtx {
    tenant_id: "acme".into(),
    user_role: "viewer".into(),
};
let result = proxy
    .execute("{ user { name email } }", &json!({}), ctx)
    .await?;

Interceptor patterns

Resolve locally (skip upstream entirely)

.intercept(("Query", "serverTime"), |_ctx| async move {
    Ok(json!(chrono::Utc::now().to_rfc3339()))
})

Forward and transform

.intercept(("User", "email"), |ctx| async move {
    let value = ctx.forward().await?;
    let masked = value.as_str().unwrap_or("").replace("@", "[at]");
    Ok(json!(masked))
})

Cache with conditional forwarding

.intercept(("User", "profile"), |ctx| async move {
    let cache_key = format!(
        "profile:{}:{}",
        ctx.state().tenant_id,
        ctx.parent().get("id").unwrap()
    );

    if let Some(cached) = my_cache.get(&cache_key).await {
        return Ok(cached);
    }

    let value = ctx.forward().await?;
    my_cache.set(&cache_key, &value).await;
    Ok(value)
})

Use parent fields

.intercept(("User", "displayLabel"), |ctx| async move {
    let name = ctx.parent().get("name").and_then(|v| v.as_str()).unwrap_or("?");
    let role = ctx.parent().get("role").and_then(|v| v.as_str()).unwrap_or("user");
    Ok(json!(format!("{} ({})", name, role)))
})

InterceptContext API

Inside an interceptor closure, ctx provides:

Method Description
ctx.forward() Call upstream for this field and return the result
ctx.parent() The parent object's fields (from upstream response)
ctx.state() Per-request state passed to execute()
ctx.field_name() Name of the intercepted field
ctx.args() Arguments passed to this field in the query

Roadmap

  • Batched forwarding — collect all forward() calls and batch into a single upstream request instead of individual calls per field
  • Wildcard selectors("*", "email") to intercept a field on any type
  • forward_with — modify the sub-selection before forwarding to upstream

License

MIT