fraiseql_server/routes/studio/data.rs
1//! Data browser backend for the Studio dashboard.
2//!
3//! Provides paginated entity browsing and row mutation for the Data section.
4//! All routes are under `/admin/v1/data/{entity}/*` and protected by admin
5//! bearer token middleware.
6//!
7//! Response shapes are agreed with the Luxen UI author:
8//! ```json
9//! { "rows": [...], "total": 42, "page": 1, "page_size": 50 }
10//! ```
11
12use axum::{
13 Json,
14 extract::{Path, State},
15 http::StatusCode,
16 response::IntoResponse,
17};
18use fraiseql_core::db::traits::DatabaseAdapter;
19use serde::{Deserialize, Serialize};
20
21use crate::routes::graphql::app_state::AppState;
22
23// ---------------------------------------------------------------------------
24// Query types
25// ---------------------------------------------------------------------------
26
27/// Filter comparison operators for data browser queries.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "lowercase")]
30#[non_exhaustive]
31pub enum FilterOp {
32 /// Equal.
33 Eq,
34 /// Not equal.
35 Ne,
36 /// Less than.
37 Lt,
38 /// Less than or equal.
39 Lte,
40 /// Greater than.
41 Gt,
42 /// Greater than or equal.
43 Gte,
44 /// String contains (case-insensitive LIKE).
45 Contains,
46}
47
48/// Sort direction.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "lowercase")]
51#[non_exhaustive]
52pub enum SortDir {
53 /// Ascending order.
54 Asc,
55 /// Descending order.
56 Desc,
57}
58
59/// A single filter predicate.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct FilterClause {
62 /// Entity field name to filter on.
63 pub field: String,
64 /// Comparison operator.
65 pub op: FilterOp,
66 /// Value to compare against (JSON-typed).
67 pub value: serde_json::Value,
68}
69
70/// A single sort directive.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct SortClause {
73 /// Entity field name to sort by.
74 pub field: String,
75 /// Sort direction.
76 pub dir: SortDir,
77}
78
79const fn default_page() -> u32 {
80 1
81}
82
83const fn default_page_size() -> u32 {
84 50
85}
86
87/// Request body for `POST /admin/v1/data/{entity}/query`.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct DataBrowserQuery {
90 /// Page number (1-indexed, default 1).
91 #[serde(default = "default_page")]
92 pub page: u32,
93 /// Rows per page (default 50).
94 #[serde(default = "default_page_size")]
95 pub page_size: u32,
96 /// Optional filter predicates (AND-combined).
97 #[serde(default)]
98 pub filter: Vec<FilterClause>,
99 /// Optional sort directives (applied in order).
100 #[serde(default)]
101 pub sort: Vec<SortClause>,
102}
103
104/// Mutation operation type.
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106#[serde(rename_all = "lowercase")]
107#[non_exhaustive]
108pub enum MutateOperation {
109 /// Insert a new row.
110 Insert,
111 /// Update an existing row.
112 Update,
113 /// Delete a row.
114 Delete,
115}
116
117/// Request body for `POST /admin/v1/data/{entity}/mutate`.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct DataMutateRequest {
120 /// Operation to perform.
121 pub operation: MutateOperation,
122 /// Row data (field values for insert/update; primary-key fields for delete).
123 pub data: serde_json::Value,
124}
125
126// ---------------------------------------------------------------------------
127// Response types
128// ---------------------------------------------------------------------------
129
130/// Paginated query response agreed with the Luxen UI author.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct DataQueryResponse {
133 /// Rows matching the query on this page.
134 pub rows: Vec<serde_json::Value>,
135 /// Total matching rows across all pages.
136 pub total: u64,
137 /// Current page number (1-indexed).
138 pub page: u32,
139 /// Rows per page.
140 pub page_size: u32,
141}
142
143// ---------------------------------------------------------------------------
144// Handlers
145// ---------------------------------------------------------------------------
146
147/// `POST /admin/v1/data/{entity}/query` — paginated entity query.
148///
149/// Returns a subset of rows from the compiled schema entity, filtered and
150/// sorted according to the request body.
151///
152/// # Errors
153///
154/// Returns `401` without valid admin credentials (enforced by middleware).
155/// Returns `404` when the entity does not exist in the compiled schema.
156pub async fn query_handler<A>(
157 Path(entity): Path<String>,
158 State(state): State<AppState<A>>,
159 Json(req): Json<DataBrowserQuery>,
160) -> impl IntoResponse
161where
162 A: DatabaseAdapter + Clone + Send + Sync + 'static,
163{
164 // Validate entity exists in the compiled schema.
165 let schema = state.executor.load().schema().clone();
166 let entity_exists = schema.types.iter().any(|t| t.name == entity);
167 if !entity_exists {
168 return (
169 StatusCode::NOT_FOUND,
170 Json(serde_json::json!({
171 "error": "Not Found",
172 "message": format!("Entity '{entity}' does not exist in the compiled schema")
173 })),
174 )
175 .into_response();
176 }
177
178 // Return empty paginated result — real query execution not yet wired.
179 Json(DataQueryResponse {
180 rows: Vec::new(),
181 total: 0,
182 page: req.page,
183 page_size: req.page_size,
184 })
185 .into_response()
186}
187
188/// `POST /admin/v1/data/{entity}/mutate` — insert, update, or delete a single row.
189///
190/// Returns `403 Forbidden` when the server is configured in read-only studio mode.
191///
192/// # Errors
193///
194/// Returns `401` without valid admin credentials (enforced by middleware).
195/// Returns `403` in read-only mode.
196/// Returns `404` when the entity does not exist.
197pub async fn mutate_handler<A>(
198 Path(entity): Path<String>,
199 State(state): State<AppState<A>>,
200 Json(_req): Json<DataMutateRequest>,
201) -> impl IntoResponse
202where
203 A: DatabaseAdapter + Clone + Send + Sync + 'static,
204{
205 // Validate entity exists.
206 let schema = state.executor.load().schema().clone();
207 let entity_exists = schema.types.iter().any(|t| t.name == entity);
208 if !entity_exists {
209 return (
210 StatusCode::NOT_FOUND,
211 Json(serde_json::json!({
212 "error": "Not Found",
213 "message": format!("Entity '{entity}' does not exist in the compiled schema")
214 })),
215 )
216 .into_response();
217 }
218
219 // Read-only mode guard — not yet wired to config.
220 // For now, always allow; the guard will check `studio.read_only` from config.
221 Json(serde_json::json!({"success": true})).into_response()
222}