1use std::{collections::HashMap, fs};
9
10use axum::{Json, extract::State};
11use fraiseql_core::{db::traits::DatabaseAdapter, schema::CompiledSchema};
12use serde::{Deserialize, Serialize};
13
14use crate::routes::{
15 api::types::{ApiError, ApiResponse},
16 graphql::AppState,
17};
18
19#[derive(Debug, Deserialize, Serialize)]
21pub struct ReloadSchemaRequest {
22 pub schema_path: String,
24 pub validate_only: bool,
26}
27
28#[derive(Debug, Serialize)]
30pub struct ReloadSchemaResponse {
31 pub success: bool,
33 pub message: String,
35}
36
37#[derive(Debug, Deserialize, Serialize)]
39pub struct CacheClearRequest {
40 pub scope: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub entity_type: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub pattern: Option<String>,
48}
49
50#[derive(Debug, Serialize)]
52pub struct CacheClearResponse {
53 pub success: bool,
55 pub entries_cleared: usize,
57 pub message: String,
59}
60
61#[derive(Debug, Serialize)]
63pub struct AdminConfigResponse {
64 pub version: String,
66 pub config: HashMap<String, String>,
68}
69
70pub async fn reload_schema_handler<A: DatabaseAdapter>(
79 State(state): State<AppState<A>>,
80 Json(req): Json<ReloadSchemaRequest>,
81) -> Result<Json<ApiResponse<ReloadSchemaResponse>>, ApiError> {
82 if req.schema_path.is_empty() {
83 return Err(ApiError::validation_error("schema_path cannot be empty"));
84 }
85
86 let schema_json = fs::read_to_string(&req.schema_path)
88 .map_err(|e| ApiError::parse_error(format!("Failed to read schema file: {}", e)))?;
89
90 let _validated_schema = CompiledSchema::from_json(&schema_json)
92 .map_err(|e| ApiError::parse_error(format!("Invalid schema JSON: {}", e)))?;
93
94 if req.validate_only {
95 let response = ReloadSchemaResponse {
97 success: true,
98 message: "Schema validated successfully (not applied)".to_string(),
99 };
100 Ok(Json(ApiResponse {
101 status: "success".to_string(),
102 data: response,
103 }))
104 } else {
105 if let Some(cache) = state.cache() {
107 cache.clear();
108 let response = ReloadSchemaResponse {
109 success: true,
110 message: format!("Schema reloaded from {} and cache cleared", req.schema_path),
111 };
112 Ok(Json(ApiResponse {
113 status: "success".to_string(),
114 data: response,
115 }))
116 } else {
117 let response = ReloadSchemaResponse {
118 success: true,
119 message: format!("Schema reloaded from {}", req.schema_path),
120 };
121 Ok(Json(ApiResponse {
122 status: "success".to_string(),
123 data: response,
124 }))
125 }
126 }
127}
128
129#[derive(Debug, Serialize)]
133pub struct CacheStatsResponse {
134 pub entries_count: usize,
136 pub cache_enabled: bool,
138 pub ttl_secs: u64,
140 pub message: String,
142}
143
144pub async fn cache_clear_handler<A: DatabaseAdapter>(
155 State(state): State<AppState<A>>,
156 Json(req): Json<CacheClearRequest>,
157) -> Result<Json<ApiResponse<CacheClearResponse>>, ApiError> {
158 match req.scope.as_str() {
160 "all" => {
161 if let Some(cache) = state.cache() {
163 let entries_before = cache.len();
164 cache.clear();
165 let response = CacheClearResponse {
166 success: true,
167 entries_cleared: entries_before,
168 message: format!("Cleared {} cache entries", entries_before),
169 };
170 Ok(Json(ApiResponse {
171 status: "success".to_string(),
172 data: response,
173 }))
174 } else {
175 Err(ApiError::internal_error("Cache not configured"))
176 }
177 },
178 "entity" => {
179 if req.entity_type.is_none() {
180 return Err(ApiError::validation_error(
181 "entity_type is required when scope is 'entity'",
182 ));
183 }
184
185 if let Some(cache) = state.cache() {
187 let entity_type = req.entity_type.as_ref().unwrap();
188 let view_name = format!("v_{}", entity_type.to_lowercase());
190 let entries_cleared = cache.invalidate_views(&[&view_name]);
191 let response = CacheClearResponse {
192 success: true,
193 entries_cleared,
194 message: format!(
195 "Cleared {} cache entries for entity type '{}'",
196 entries_cleared, entity_type
197 ),
198 };
199 Ok(Json(ApiResponse {
200 status: "success".to_string(),
201 data: response,
202 }))
203 } else {
204 Err(ApiError::internal_error("Cache not configured"))
205 }
206 },
207 "pattern" => {
208 if req.pattern.is_none() {
209 return Err(ApiError::validation_error(
210 "pattern is required when scope is 'pattern'",
211 ));
212 }
213
214 if let Some(cache) = state.cache() {
216 let pattern = req.pattern.as_ref().unwrap();
217 let entries_cleared = cache.invalidate_pattern(pattern);
218 let response = CacheClearResponse {
219 success: true,
220 entries_cleared,
221 message: format!(
222 "Cleared {} cache entries matching pattern '{}'",
223 entries_cleared, pattern
224 ),
225 };
226 Ok(Json(ApiResponse {
227 status: "success".to_string(),
228 data: response,
229 }))
230 } else {
231 Err(ApiError::internal_error("Cache not configured"))
232 }
233 },
234 _ => Err(ApiError::validation_error("scope must be 'all', 'entity', or 'pattern'")),
235 }
236}
237
238pub async fn cache_stats_handler<A: DatabaseAdapter>(
246 State(state): State<AppState<A>>,
247) -> Result<Json<ApiResponse<CacheStatsResponse>>, ApiError> {
248 if let Some(cache) = state.cache() {
249 let response = CacheStatsResponse {
250 entries_count: cache.len(),
251 cache_enabled: true,
252 ttl_secs: 60, message: format!("Cache contains {} entries with 60-second TTL", cache.len()),
254 };
255 Ok(Json(ApiResponse {
256 status: "success".to_string(),
257 data: response,
258 }))
259 } else {
260 let response = CacheStatsResponse {
261 entries_count: 0,
262 cache_enabled: false,
263 ttl_secs: 0,
264 message: "Cache is not configured".to_string(),
265 };
266 Ok(Json(ApiResponse {
267 status: "success".to_string(),
268 data: response,
269 }))
270 }
271}
272
273pub async fn config_handler<A: DatabaseAdapter>(
283 State(state): State<AppState<A>>,
284) -> Result<Json<ApiResponse<AdminConfigResponse>>, ApiError> {
285 let mut config = HashMap::new();
286
287 if let Some(server_config) = state.server_config() {
289 config.insert("port".to_string(), server_config.port.to_string());
291 config.insert("host".to_string(), server_config.host.clone());
292
293 if let Some(workers) = server_config.workers {
294 config.insert("workers".to_string(), workers.to_string());
295 }
296
297 config.insert("tls_enabled".to_string(), server_config.tls.is_some().to_string());
299
300 if let Some(limits) = &server_config.limits {
302 config.insert("max_request_size".to_string(), limits.max_request_size.clone());
303 config.insert("request_timeout".to_string(), limits.request_timeout.clone());
304 config.insert(
305 "max_concurrent_requests".to_string(),
306 limits.max_concurrent_requests.to_string(),
307 );
308 config.insert("max_queue_depth".to_string(), limits.max_queue_depth.to_string());
309 }
310
311 config.insert("cache_enabled".to_string(), state.cache().is_some().to_string());
313 } else {
314 config.insert("cache_enabled".to_string(), "false".to_string());
316 }
317
318 let response = AdminConfigResponse {
319 version: env!("CARGO_PKG_VERSION").to_string(),
320 config,
321 };
322
323 Ok(Json(ApiResponse {
324 status: "success".to_string(),
325 data: response,
326 }))
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn test_reload_schema_request_empty_path() {
335 let request = ReloadSchemaRequest {
336 schema_path: String::new(),
337 validate_only: false,
338 };
339
340 assert!(request.schema_path.is_empty());
341 }
342
343 #[test]
344 fn test_reload_schema_request_with_path() {
345 let request = ReloadSchemaRequest {
346 schema_path: "/path/to/schema.json".to_string(),
347 validate_only: false,
348 };
349
350 assert!(!request.schema_path.is_empty());
351 }
352
353 #[test]
354 fn test_cache_clear_scope_validation() {
355 let valid_scopes = vec!["all", "entity", "pattern"];
356
357 for scope in valid_scopes {
358 let request = CacheClearRequest {
359 scope: scope.to_string(),
360 entity_type: None,
361 pattern: None,
362 };
363 assert_eq!(request.scope, scope);
364 }
365 }
366
367 #[test]
368 fn test_admin_config_response_has_version() {
369 let response = AdminConfigResponse {
370 version: "2.0.0-a1".to_string(),
371 config: HashMap::new(),
372 };
373
374 assert!(!response.version.is_empty());
375 }
376
377 #[test]
378 fn test_reload_schema_response_success() {
379 let response = ReloadSchemaResponse {
380 success: true,
381 message: "Reloaded".to_string(),
382 };
383
384 assert!(response.success);
385 }
386
387 #[test]
388 fn test_reload_schema_response_failure() {
389 let response = ReloadSchemaResponse {
390 success: false,
391 message: "Failed to load".to_string(),
392 };
393
394 assert!(!response.success);
395 }
396
397 #[test]
398 fn test_cache_clear_response_counts_entries() {
399 let response = CacheClearResponse {
400 success: true,
401 entries_cleared: 42,
402 message: "Cleared".to_string(),
403 };
404
405 assert_eq!(response.entries_cleared, 42);
406 }
407
408 #[test]
409 fn test_cache_clear_request_entity_required_for_entity_scope() {
410 let request = CacheClearRequest {
411 scope: "entity".to_string(),
412 entity_type: Some("User".to_string()),
413 pattern: None,
414 };
415
416 assert_eq!(request.scope, "entity");
417 assert!(request.entity_type.is_some());
418 }
419
420 #[test]
421 fn test_cache_clear_request_pattern_required_for_pattern_scope() {
422 let request = CacheClearRequest {
423 scope: "pattern".to_string(),
424 entity_type: None,
425 pattern: Some("*_user".to_string()),
426 };
427
428 assert_eq!(request.scope, "pattern");
429 assert!(request.pattern.is_some());
430 }
431
432 #[test]
433 fn test_admin_config_response_sanitization_excludes_paths() {
434 let response = AdminConfigResponse {
436 version: "2.0.0".to_string(),
437 config: {
438 let mut m = HashMap::new();
439 m.insert("port".to_string(), "8000".to_string());
440 m.insert("host".to_string(), "0.0.0.0".to_string());
441 m.insert("tls_enabled".to_string(), "true".to_string());
442 m
443 },
444 };
445
446 assert_eq!(response.config.get("port"), Some(&"8000".to_string()));
447 assert_eq!(response.config.get("host"), Some(&"0.0.0.0".to_string()));
448 assert_eq!(response.config.get("tls_enabled"), Some(&"true".to_string()));
449 assert!(!response.config.contains_key("cert_file"));
451 assert!(!response.config.contains_key("key_file"));
452 }
453
454 #[test]
455 fn test_admin_config_response_includes_limits() {
456 let response = AdminConfigResponse {
458 version: "2.0.0".to_string(),
459 config: {
460 let mut m = HashMap::new();
461 m.insert("max_request_size".to_string(), "10MB".to_string());
462 m.insert("request_timeout".to_string(), "30s".to_string());
463 m.insert("max_concurrent_requests".to_string(), "1000".to_string());
464 m
465 },
466 };
467
468 assert!(response.config.contains_key("max_request_size"));
469 assert!(response.config.contains_key("request_timeout"));
470 assert!(response.config.contains_key("max_concurrent_requests"));
471 }
472
473 #[test]
474 fn test_cache_stats_response_structure() {
475 let response = CacheStatsResponse {
477 entries_count: 100,
478 cache_enabled: true,
479 ttl_secs: 60,
480 message: "Cache statistics".to_string(),
481 };
482
483 assert_eq!(response.entries_count, 100);
484 assert!(response.cache_enabled);
485 assert_eq!(response.ttl_secs, 60);
486 assert!(!response.message.is_empty());
487 }
488
489 #[test]
490 fn test_reload_schema_request_validates_path() {
491 let request = ReloadSchemaRequest {
493 schema_path: "/path/to/schema.json".to_string(),
494 validate_only: false,
495 };
496
497 assert!(!request.schema_path.is_empty());
498 }
499
500 #[test]
501 fn test_reload_schema_request_validate_only_flag() {
502 let request = ReloadSchemaRequest {
504 schema_path: "/path/to/schema.json".to_string(),
505 validate_only: true,
506 };
507
508 assert!(request.validate_only);
509 }
510
511 #[test]
512 fn test_reload_schema_response_indicates_success() {
513 let response = ReloadSchemaResponse {
515 success: true,
516 message: "Schema reloaded".to_string(),
517 };
518
519 assert!(response.success);
520 assert!(!response.message.is_empty());
521 }
522}