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}