Skip to main content

fraiseql_server/routes/api/
metadata.rs

1//! Schema field security metadata flattening and HTTP handler.
2//!
3//! Walks the compiled schema and collects non-default security annotations
4//! (field encryption, scope requirements, type-level role guards) into a flat
5//! `BTreeMap` keyed by `"TypeName"` (type-level) or `"TypeName.fieldName"`
6//! (field-level).
7//!
8//! The `metadata_handler` exposes this map at `GET /api/v1/schema/metadata`.
9
10use std::collections::BTreeMap;
11
12use axum::{Json, extract::State};
13use fraiseql_core::{
14    db::traits::DatabaseAdapter,
15    schema::{CompiledSchema, FieldDenyPolicy},
16};
17use serde::Serialize;
18
19use crate::routes::{api::types::ApiResponse, graphql::AppState};
20
21/// Security metadata for a single field or type in the compiled schema.
22///
23/// Only non-default annotations are populated; all fields are `Option` and
24/// serialise with `skip_serializing_if = "Option::is_none"` so that the JSON
25/// output is minimal.
26///
27/// The struct is `#[non_exhaustive]` to allow adding new annotation kinds in
28/// future releases without breaking downstream pattern matches.
29#[non_exhaustive]
30#[derive(Debug, Clone, PartialEq, Serialize)]
31pub struct FieldSecurityMetadata {
32    /// `true` when this field is encrypted at rest.
33    ///
34    /// Only present (and always `true`) for fields that carry an
35    /// `FieldEncryptionConfig`.  Absent when the field is not encrypted.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub encrypted: Option<bool>,
38
39    /// OAuth 2.0 scope required to read this field.
40    ///
41    /// When `None`, the field is visible to any authenticated user (subject to
42    /// type-level `requires_role`).
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub requires_scope: Option<String>,
45
46    /// Policy applied when the required scope is absent.
47    ///
48    /// Possible values: `"reject"`, `"mask"`.
49    /// Only serialised when the policy is non-default (i.e., not `"reject"`).
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub on_deny: Option<String>,
52
53    /// Role required to access this type (type-level guard).
54    ///
55    /// Only present on type-keyed entries (keys without a `.` separator).
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub requires_role: Option<String>,
58}
59
60impl FieldSecurityMetadata {
61    /// Returns `true` if all annotations are absent (entry should be omitted).
62    #[must_use]
63    pub const fn is_empty(&self) -> bool {
64        self.encrypted.is_none()
65            && self.requires_scope.is_none()
66            && self.on_deny.is_none()
67            && self.requires_role.is_none()
68    }
69}
70
71/// Response body for `GET /api/v1/schema/metadata`.
72#[non_exhaustive]
73#[derive(Debug, Serialize)]
74pub struct MetadataResponse {
75    /// Flat map of security annotations, keyed by `"TypeName"` or `"TypeName.fieldName"`.
76    pub metadata: BTreeMap<String, FieldSecurityMetadata>,
77}
78
79/// Return field-level security metadata for the compiled schema.
80///
81/// Walks all types and fields, collecting non-default security annotations
82/// (encryption, scope requirements, deny policies, role guards) into a flat
83/// map and returns it as a JSON object.
84///
85/// The handler is infallible — the schema is always present in `AppState`.
86pub async fn metadata_handler<A: DatabaseAdapter>(
87    State(state): State<AppState<A>>,
88) -> Json<ApiResponse<MetadataResponse>> {
89    let metadata = flatten_field_metadata(state.executor().schema());
90    Json(ApiResponse {
91        status: "success".to_string(),
92        data:   MetadataResponse { metadata },
93    })
94}
95
96/// Flatten all non-default security annotations from a compiled schema into a map.
97///
98/// Keys use the following format:
99/// - `"TypeName"` — type-level annotations (`requires_role`)
100/// - `"TypeName.fieldName"` — field-level annotations (`encrypted`, `requires_scope`, `on_deny`)
101///
102/// Types and fields with all-default annotations are omitted from the result.
103/// The map is ordered (`BTreeMap`) so that the output is deterministic.
104#[must_use]
105pub fn flatten_field_metadata(schema: &CompiledSchema) -> BTreeMap<String, FieldSecurityMetadata> {
106    let mut map = BTreeMap::new();
107
108    for type_def in &schema.types {
109        let type_name = type_def.name.as_str();
110
111        // ── Type-level: requires_role ─────────────────────────────────────────
112        if let Some(role) = &type_def.requires_role {
113            map.insert(
114                type_name.to_string(),
115                FieldSecurityMetadata {
116                    encrypted:      None,
117                    requires_scope: None,
118                    on_deny:        None,
119                    requires_role:  Some(role.clone()),
120                },
121            );
122        }
123
124        // ── Field-level annotations ───────────────────────────────────────────
125        for field in &type_def.fields {
126            let encrypted = field.encryption.as_ref().map(|_| true);
127            let requires_scope = field.requires_scope.clone();
128            let on_deny = match field.on_deny {
129                FieldDenyPolicy::Mask => Some("mask".to_string()),
130                // FieldDenyPolicy is #[non_exhaustive]; all other variants (including
131                // the default Reject) produce no output.
132                _ => None,
133            };
134
135            let meta = FieldSecurityMetadata {
136                encrypted,
137                requires_scope,
138                on_deny,
139                requires_role: None,
140            };
141
142            if !meta.is_empty() {
143                map.insert(format!("{type_name}.{}", field.name), meta);
144            }
145        }
146    }
147
148    map
149}