Skip to main content

fraiseql_server/routes/
introspection.rs

1//! Schema introspection endpoint.
2
3use axum::{Json, extract::State, response::IntoResponse};
4use fraiseql_core::db::traits::DatabaseAdapter;
5use serde::Serialize;
6use tracing::debug;
7
8use crate::{extractors::OptionalSecurityContext, routes::graphql::AppState};
9
10/// Introspection response.
11#[derive(Debug, Serialize)]
12pub struct IntrospectionResponse {
13    /// Schema types.
14    pub types: Vec<TypeInfo>,
15
16    /// Schema queries.
17    pub queries: Vec<QueryInfo>,
18
19    /// Schema mutations.
20    pub mutations: Vec<MutationInfo>,
21}
22
23/// Type information.
24#[derive(Debug, Serialize)]
25pub struct TypeInfo {
26    /// Type name.
27    pub name: String,
28
29    /// Type description.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub description: Option<String>,
32
33    /// Field count.
34    pub field_count: usize,
35}
36
37/// Query information.
38#[derive(Debug, Serialize)]
39pub struct QueryInfo {
40    /// Query name.
41    pub name: String,
42
43    /// Return type.
44    pub return_type: String,
45
46    /// Returns list.
47    pub returns_list: bool,
48
49    /// Query description.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub description: Option<String>,
52}
53
54/// Mutation information.
55#[derive(Debug, Serialize)]
56pub struct MutationInfo {
57    /// Mutation name.
58    pub name: String,
59
60    /// Return type.
61    pub return_type: String,
62
63    /// Mutation description.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub description: Option<String>,
66}
67
68/// Introspection handler.
69///
70/// Returns schema structure for debugging and tooling.
71/// Types and queries with `requires_role` are filtered based on the
72/// caller's roles — hidden types/queries never appear in introspection
73/// to prevent role enumeration.
74///
75/// # Security Note
76///
77/// In production, this endpoint should be disabled or require authentication.
78pub async fn introspection_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
79    State(state): State<AppState<A>>,
80    OptionalSecurityContext(security_context): OptionalSecurityContext,
81) -> impl IntoResponse {
82    debug!("Introspection requested");
83
84    let executor = state.executor();
85    let schema = executor.schema();
86
87    let user_roles: Vec<&str> = security_context
88        .as_ref()
89        .map(|ctx| ctx.roles.iter().map(String::as_str).collect())
90        .unwrap_or_default();
91
92    let types: Vec<TypeInfo> = schema
93        .types
94        .iter()
95        .filter(|t| t.requires_role.as_ref().is_none_or(|role| user_roles.contains(&role.as_str())))
96        .map(|t| TypeInfo {
97            name:        t.name.to_string(),
98            description: t.description.clone(),
99            field_count: t.fields.len(),
100        })
101        .collect();
102
103    let queries: Vec<QueryInfo> = schema
104        .queries
105        .iter()
106        .filter(|q| q.requires_role.as_ref().is_none_or(|role| user_roles.contains(&role.as_str())))
107        .map(|q| QueryInfo {
108            name:         schema.display_name(&q.name),
109            return_type:  q.return_type.clone(),
110            returns_list: q.returns_list,
111            description:  q.description.clone(),
112        })
113        .collect();
114
115    let mutations: Vec<MutationInfo> = schema
116        .mutations
117        .iter()
118        .map(|m| MutationInfo {
119            name:        schema.display_name(&m.name),
120            return_type: m.return_type.clone(),
121            description: m.description.clone(),
122        })
123        .collect();
124
125    Json(IntrospectionResponse {
126        types,
127        queries,
128        mutations,
129    })
130}
131
132#[cfg(test)]
133mod tests {
134    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
135    #![allow(clippy::cast_precision_loss)] // Reason: test metrics reporting
136    #![allow(clippy::cast_sign_loss)] // Reason: test data uses small positive integers
137    #![allow(clippy::cast_possible_truncation)] // Reason: test data values are bounded
138    #![allow(clippy::cast_possible_wrap)] // Reason: test data values are bounded
139    #![allow(clippy::missing_panics_doc)] // Reason: test helpers
140    #![allow(clippy::missing_errors_doc)] // Reason: test helpers
141    #![allow(missing_docs)] // Reason: test code
142    #![allow(clippy::items_after_statements)] // Reason: test helpers defined near use site
143
144    use super::*;
145
146    #[test]
147    fn test_type_info_serialization() {
148        let type_info = TypeInfo {
149            name:        "User".to_string(),
150            description: Some("A user in the system".to_string()),
151            field_count: 3,
152        };
153
154        let json = serde_json::to_string(&type_info).unwrap();
155        assert!(json.contains("User"));
156        assert!(json.contains("field_count"));
157    }
158}