#Guard Patterns Cookbook

Version: 0.36.0 | Updated: 2026-03-20 | Applies to: ranvier-guard 0.36+ | Category: Cookbook


#Overview

Guards are typed Transition<T, T> nodes that enforce security and policy constraints as visible, traceable pipeline steps. Instead of hidden Tower middleware layers, Guards are first-class pipeline citizens that read from and write to the Bus.

This cookbook covers the most common Guard composition patterns.


#1. Global Guard Registration

Register Guards on HttpIngress with .guard(). Global Guards run on every request, in registration order, before any route circuit executes.

use ranvier_http::prelude::*;
use ranvier_guard::prelude::*;

Ranvier::http()
    .guard(RequestIdGuard::new())
    .guard(AccessLogGuard::new())
    .guard(CorsGuard::new(CorsConfig {
        allowed_origins: vec!["https://app.example.com".into()],
        allowed_methods: vec!["GET".into(), "POST".into(), "PUT".into(), "DELETE".into()],
        allowed_headers: vec!["Content-Type".into(), "Authorization".into()],
        max_age_seconds: 86400,
        allow_credentials: true,
    }))
    .guard(SecurityHeadersGuard::new(SecurityPolicy::default()))
    .guard(CompressionGuard::new().prefer_brotli())
    .get("/api/health", health_circuit)
    .run(())
    .await?;

Execution order: RequestIdGuard -> AccessLogGuard -> CorsGuard -> SecurityHeadersGuard -> CompressionGuard -> route circuit.


#2. Per-Route Guards with `guards![]`

Use post_with_guards() (or get_with_guards, put_with_guards, etc.) with the guards![] macro to apply Guards to specific routes only. Per-route Guards execute after global Guards.

use ranvier_http::{guards, prelude::*};
use ranvier_guard::prelude::*;

Ranvier::http()
    .guard(AccessLogGuard::new())                    // global — all routes
    .guard(CorsGuard::<()>::permissive())    // global — all routes
    .post_with_guards("/api/orders", order_circuit, guards![
        ContentTypeGuard::json(),                    // per-route — POST only
        IdempotencyGuard::ttl_5min(),                // per-route — POST only
        RequestSizeLimitGuard::max_2mb(),            // per-route — POST only
    ])
    .get("/api/orders", list_circuit)                // no extra guards
    .run(())
    .await?;

The guards![] macro calls GuardIntegration::register() on each guard expression, producing a Vec<RegisteredGuard>.


#3. Custom Guard Implementation

Any Transition<T, T> can serve as a Guard. The pattern is straightforward: read context from the Bus, validate, then either pass the input through (Outcome::next) or reject it (Outcome::fault).

use async_trait::async_trait;
use ranvier_core::prelude::*;
use serde::{Deserialize, Serialize};

/// Bus type: API version extracted from the Accept header.
#[derive(Debug, Clone)]
pub struct ApiVersion(pub String);

/// Guard that rejects requests without a supported API version.
#[derive(Debug, Clone)]
pub struct ApiVersionGuard<T> {
    supported: Vec<String>,
    _marker: std::marker::PhantomData<T>,
}

impl<T> ApiVersionGuard<T> {
    pub fn new(supported: Vec<String>) -> Self {
        Self {
            supported,
            _marker: std::marker::PhantomData,
        }
    }
}

#[async_trait]
impl<T: Send + Sync + 'static> Transition<T, T> for ApiVersionGuard<T> {
    type Error = String;
    type Resources = ();

    async fn run(
        &self,
        input: T,
        _resources: &Self::Resources,
        bus: &mut Bus,
    ) -> Outcome<T, Self::Error> {
        let version = bus.get_cloned::<ApiVersion>()
            .map(|v| v.0)
            .unwrap_or_default();

        if self.supported.contains(&version) {
            Outcome::next(input)
        } else {
            Outcome::fault(format!(
                "406 Not Acceptable: API version '{}' not supported",
                version
            ))
        }
    }
}

#4. Guard Bus Injection

Guards communicate through the Bus. The GuardIntegration trait automatically wires HTTP headers into Bus types and Bus types back into response headers.

Read flow: HTTP Request -> BusInjectorFn -> Bus -> Guard reads Write flow: Guard writes -> Bus -> ResponseExtractorFn -> HTTP Response

Built-in Bus wiring examples:

Guard Reads from Bus Writes to Bus
CorsGuard RequestOrigin CorsHeaders
AuthGuard AuthorizationHeader IamIdentity
RateLimitGuard ClientIdentity --
CompressionGuard AcceptEncoding CompressionConfig
RequestIdGuard RequestId (optional) RequestId
ContentTypeGuard RequestContentType --
TimeoutGuard -- TimeoutDeadline
IdempotencyGuard IdempotencyKey IdempotencyCachedResponse

#Production `bus_injector` Pattern

When using Guards alongside application data, inject the database pool, PathParams, and request headers in a single bus_injector closure:

use ranvier_http::{PathParams, prelude::*};
use ranvier_guard::prelude::*;
use sqlx::PgPool;

let pool = PgPool::connect(&database_url).await?;

Ranvier::http()
    .bind("0.0.0.0:8080")
    .bus_injector({
        let pool = pool.clone();
        move |parts: &http::request::Parts, bus: &mut Bus| {
            // Application data
            bus.insert(pool.clone());
            // Path parameters (auto-extracted by HttpIngress)
            if let Some(params) = parts.extensions.get::<PathParams>() {
                bus.insert(params.clone());
            }
            // Request headers as Vec<(String, String)>
            let headers: Vec<(String, String)> = parts.headers.iter()
                .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
                .collect();
            bus.insert(headers);
        }
    })
    // Guards auto-inject their own Bus types (RequestOrigin, etc.)
    .guard(CorsGuard::<()>::permissive())
    .guard(AccessLogGuard::new())
    .post_typed("/api/orders", order_pipeline())
    .get("/api/orders/:id", get_order_pipeline())
    .run(())
    .await?;

Guard Bus types (RequestOrigin, CorsHeaders, etc.) are injected automatically by the GuardIntegration trait -- you only need to inject application-specific data in bus_injector.


#5. Authentication Guard Patterns

#Bearer Token

use ranvier_guard::prelude::*;

// Simple token validation
let auth = AuthGuard::bearer(vec![
    std::env::var("API_TOKEN").expect("API_TOKEN must be set"),
]);

Ranvier::http()
    .guard(auth)
    .get("/api/protected", protected_circuit)
    .run(())
    .await?;

#Bearer + Role Policy

use ranvier_core::iam::IamPolicy;
use ranvier_guard::prelude::*;

let admin_auth = AuthGuard::bearer(vec!["admin-token".into()])
    .with_policy(IamPolicy::RequireRole("admin".into()));

Ranvier::http()
    .get_with_guards("/api/admin", admin_circuit, guards![admin_auth])
    .run(())
    .await?;

#Custom JWT Validator

use ranvier_core::iam::IamIdentity;
use ranvier_guard::prelude::*;

let jwt_auth = AuthGuard::custom(|header_value| {
    let token = header_value
        .strip_prefix("Bearer ")
        .ok_or("expected Bearer scheme")?;
    // Your JWT validation logic here
    let claims = validate_jwt(token).map_err(|e| e.to_string())?;
    Ok(IamIdentity::new(&claims.sub).with_role(&claims.role))
});

#6. Real-World Example: API Server

Combining CORS + Auth + RateLimit + Compression + Logging for a production API:

use ranvier_http::{guards, prelude::*};
use ranvier_guard::prelude::*;
use std::time::Duration;

let api_token = std::env::var("API_TOKEN").expect("API_TOKEN required");

Ranvier::http()
    .bind("0.0.0.0:8080")
    // --- Global Guards (all routes) ---
    .guard(RequestIdGuard::new())
    .guard(AccessLogGuard::new().redact_paths(vec!["/auth/login".into()]))
    .guard(CorsGuard::new(CorsConfig {
        allowed_origins: vec!["https://app.example.com".into()],
        ..Default::default()
    }))
    .guard(SecurityHeadersGuard::new(
        SecurityPolicy::default().with_csp("default-src 'self'"),
    ))
    .guard(RateLimitGuard::new(100, 60_000))  // 100 req/min
    .guard(CompressionGuard::new().prefer_brotli())
    // --- Auth-protected routes ---
    .get_with_guards("/api/users", list_users, guards![
        AuthGuard::bearer(vec![api_token.clone()]),
    ])
    .post_with_guards("/api/users", create_user, guards![
        AuthGuard::bearer(vec![api_token.clone()]),
        ContentTypeGuard::json(),
        RequestSizeLimitGuard::max_2mb(),
        IdempotencyGuard::ttl_5min(),
    ])
    // --- Public routes (no auth guard) ---
    .get("/health", health_circuit)
    .run(())
    .await?;

#7. Guard Error Responses

Guards encode HTTP status codes in their error messages. The GuardIntegration layer parses "NNN " prefixes from Fault messages and sets the corresponding response status code:

  • CorsGuard -> 403 Forbidden
  • AuthGuard -> 401 Unauthorized / 403 Forbidden (policy failure)
  • RateLimitGuard -> 429 Too Many Requests
  • RequestSizeLimitGuard -> 413 Payload Too Large
  • ContentTypeGuard -> 415 Unsupported Media Type
  • TimeoutGuard -> 408 Request Timeout

If a Guard's Fault message starts with a 3-digit status code (e.g., "413 Payload Too Large"), that code is used as the HTTP response status. Otherwise, the Guard's default_status applies.


#See Also

  • Bus Access Patterns -- Bus read/write decision guide
  • HttpIngress Patterns Cookbook -- route registration patterns
  • Security Hardening Guide -- production security checklist