Skip to main content

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}