fraiseql-server 2.2.0

HTTP server for FraiseQL v2 GraphQL engine
Documentation
//! Schema introspection endpoint.

use axum::{Json, extract::State, response::IntoResponse};
use fraiseql_core::db::traits::DatabaseAdapter;
use serde::Serialize;
use tracing::debug;

use crate::{extractors::OptionalSecurityContext, routes::graphql::AppState};

/// Introspection response.
#[derive(Debug, Serialize)]
pub struct IntrospectionResponse {
    /// Schema types.
    pub types: Vec<TypeInfo>,

    /// Schema queries.
    pub queries: Vec<QueryInfo>,

    /// Schema mutations.
    pub mutations: Vec<MutationInfo>,
}

/// Type information.
#[derive(Debug, Serialize)]
pub struct TypeInfo {
    /// Type name.
    pub name: String,

    /// Type description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Field count.
    pub field_count: usize,
}

/// Query information.
#[derive(Debug, Serialize)]
pub struct QueryInfo {
    /// Query name.
    pub name: String,

    /// Return type.
    pub return_type: String,

    /// Returns list.
    pub returns_list: bool,

    /// Query description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

/// Mutation information.
#[derive(Debug, Serialize)]
pub struct MutationInfo {
    /// Mutation name.
    pub name: String,

    /// Return type.
    pub return_type: String,

    /// Mutation description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

/// Introspection handler.
///
/// Returns schema structure for debugging and tooling.
/// Types and queries with `requires_role` are filtered based on the
/// caller's roles — hidden types/queries never appear in introspection
/// to prevent role enumeration.
///
/// # Security Note
///
/// In production, this endpoint should be disabled or require authentication.
pub async fn introspection_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
    State(state): State<AppState<A>>,
    OptionalSecurityContext(security_context): OptionalSecurityContext,
) -> impl IntoResponse {
    debug!("Introspection requested");

    let executor = state.executor();
    let schema = executor.schema();

    let user_roles: Vec<&str> = security_context
        .as_ref()
        .map(|ctx| ctx.roles.iter().map(String::as_str).collect())
        .unwrap_or_default();

    let types: Vec<TypeInfo> = schema
        .types
        .iter()
        .filter(|t| t.requires_role.as_ref().is_none_or(|role| user_roles.contains(&role.as_str())))
        .map(|t| TypeInfo {
            name:        t.name.to_string(),
            description: t.description.clone(),
            field_count: t.fields.len(),
        })
        .collect();

    let queries: Vec<QueryInfo> = schema
        .queries
        .iter()
        .filter(|q| q.requires_role.as_ref().is_none_or(|role| user_roles.contains(&role.as_str())))
        .map(|q| QueryInfo {
            name:         schema.display_name(&q.name),
            return_type:  q.return_type.clone(),
            returns_list: q.returns_list,
            description:  q.description.clone(),
        })
        .collect();

    let mutations: Vec<MutationInfo> = schema
        .mutations
        .iter()
        .map(|m| MutationInfo {
            name:        schema.display_name(&m.name),
            return_type: m.return_type.clone(),
            description: m.description.clone(),
        })
        .collect();

    Json(IntrospectionResponse {
        types,
        queries,
        mutations,
    })
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
    #![allow(clippy::cast_precision_loss)] // Reason: test metrics reporting
    #![allow(clippy::cast_sign_loss)] // Reason: test data uses small positive integers
    #![allow(clippy::cast_possible_truncation)] // Reason: test data values are bounded
    #![allow(clippy::cast_possible_wrap)] // Reason: test data values are bounded
    #![allow(clippy::missing_panics_doc)] // Reason: test helpers
    #![allow(clippy::missing_errors_doc)] // Reason: test helpers
    #![allow(missing_docs)] // Reason: test code
    #![allow(clippy::items_after_statements)] // Reason: test helpers defined near use site

    use super::*;

    #[test]
    fn test_type_info_serialization() {
        let type_info = TypeInfo {
            name:        "User".to_string(),
            description: Some("A user in the system".to_string()),
            field_count: 3,
        };

        let json = serde_json::to_string(&type_info).unwrap();
        assert!(json.contains("User"));
        assert!(json.contains("field_count"));
    }
}