nestforge 1.9.0

NestJS-inspired modular backend framework for Rust
Documentation
#![cfg(feature = "graphql")]

use nestforge::{
    async_graphql::{Context, EmptyMutation, EmptySubscription, Object, Schema},
    graphql_auth_identity, graphql_request_id, graphql_router_with_config, resolve_graphql,
    AuthIdentity, Container, GraphQlConfig, NestForgeFactory, NestForgeFactoryGraphQlExt, Provider,
    RequestContext,
};
use tower::ServiceExt;

#[derive(Clone)]
struct AppConfig {
    app_name: &'static str,
}

struct QueryRoot;

#[Object]
impl QueryRoot {
    async fn health(&self, ctx: &Context<'_>) -> &str {
        let config = resolve_graphql::<AppConfig>(ctx).expect("app config should resolve");

        config.app_name
    }
}

#[tokio::test]
async fn graphql_router_accepts_post_requests() {
    let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription).finish();
    let container = Container::new();
    container
        .register(AppConfig { app_name: "ok" })
        .expect("app config should register");
    let app = graphql_router_with_config(schema, GraphQlConfig::new("/graphql").without_graphiql())
        .with_state(container);

    let response = app
        .oneshot(
            axum::http::Request::builder()
                .method("POST")
                .uri("/graphql")
                .header(axum::http::header::CONTENT_TYPE, "application/json")
                .body(axum::body::Body::from(
                    serde_json::json!({ "query": "{ health }" }).to_string(),
                ))
                .expect("request should build"),
        )
        .await
        .expect("request should succeed");

    assert_eq!(response.status(), axum::http::StatusCode::OK);
}

#[tokio::test]
async fn graphql_router_rejects_requests_above_max_body_size() {
    let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription).finish();
    let container = Container::new();
    container
        .register(AppConfig { app_name: "ok" })
        .expect("app config should register");
    let app = graphql_router_with_config(
        schema,
        GraphQlConfig::new("/graphql")
            .without_graphiql()
            .with_max_request_bytes(32),
    )
    .with_state(container);
    let payload = serde_json::json!({
        "query": "{ health }",
        "variables": {
            "payload": "this request body is intentionally too large"
        }
    })
    .to_string();

    let response = app
        .oneshot(
            axum::http::Request::builder()
                .method("POST")
                .uri("/graphql")
                .header(axum::http::header::CONTENT_TYPE, "application/json")
                .header(axum::http::header::CONTENT_LENGTH, payload.len())
                .body(axum::body::Body::from(payload))
                .expect("request should build"),
        )
        .await
        .expect("request should succeed");

    assert_eq!(response.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE);
}

#[derive(Clone)]
struct ScopedRequestInfo {
    path: String,
}

struct ScopedModule;

impl nestforge::ModuleDefinition for ScopedModule {
    fn register(container: &Container) -> anyhow::Result<()> {
        nestforge::register_provider(
            container,
            Provider::request_factory(|container| {
                let ctx = container.resolve::<RequestContext>()?;
                Ok(ScopedRequestInfo {
                    path: ctx.uri.path().to_string(),
                })
            }),
        )?;
        Ok(())
    }
}

struct ScopedQueryRoot;

#[Object]
impl ScopedQueryRoot {
    async fn request_id(&self, ctx: &Context<'_>) -> String {
        graphql_request_id(ctx)
            .expect("request id should be present")
            .value()
            .to_string()
    }

    async fn subject(&self, ctx: &Context<'_>) -> Option<String> {
        graphql_auth_identity(ctx).map(|identity| identity.subject.clone())
    }

    async fn scoped_path(&self, ctx: &Context<'_>) -> String {
        let info = resolve_graphql::<ScopedRequestInfo>(ctx).expect("scoped info should resolve");
        info.path.clone()
    }
}

#[tokio::test]
async fn graphql_router_uses_scoped_container_and_request_metadata_under_factory() {
    let schema = Schema::build(ScopedQueryRoot, EmptyMutation, EmptySubscription).finish();
    let app = NestForgeFactory::<ScopedModule>::create()
        .expect("factory should build")
        .with_auth_resolver(|token, _container| async move {
            Ok(token.map(|_| AuthIdentity::new("graphql-user")))
        })
        .with_graphql(schema)
        .into_router();

    let response = app
        .oneshot(
            axum::http::Request::builder()
                .method("POST")
                .uri("/graphql")
                .header(axum::http::header::AUTHORIZATION, "Bearer demo-token")
                .header(axum::http::header::CONTENT_TYPE, "application/json")
                .body(axum::body::Body::from(
                    serde_json::json!({
                        "query": "{ requestId subject scopedPath }"
                    })
                    .to_string(),
                ))
                .expect("request should build"),
        )
        .await
        .expect("request should succeed");

    assert_eq!(response.status(), axum::http::StatusCode::OK);
}