grafilter 0.0.1

A GraphQL proxy library with field-level interception, conditional forwarding, and response transformation
Documentation
# 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

```rust
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:

```rust
// 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:

```rust
#[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)

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

### Forward and transform

```rust
.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

```rust
.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

```rust
.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