1use crate::case::value_keys_to_camel_case;
13use crate::config::{compile_report, ReportConfig, ResolvedReport};
14use crate::error::AppError;
15use crate::extractors::tenant::{ActAsTenant, TenantId};
16use crate::extractors::user::UserId;
17use crate::handlers::config::{get_config, reload_model, replace_config};
18use crate::handlers::entity::{resolve_tenant_context, TenantContext};
19use crate::service::{CrudService, RequestValidator, TenantExecutor};
20use crate::state::AppState;
21use crate::store::DEFAULT_PACKAGE_ID;
22use axum::extract::{Path, State};
23use axum::http::StatusCode;
24use axum::response::IntoResponse;
25use axum::Json;
26use serde::Deserialize;
27use serde_json::{json, Value};
28use std::collections::HashMap;
29
30fn report_timeout_ms() -> u64 {
32 const DEFAULT: u64 = 30_000;
33 std::env::var("ARCHITECT_REPORT_TIMEOUT_MS")
34 .ok()
35 .and_then(|v| v.parse::<u64>().ok())
36 .filter(|&n| n > 0)
37 .unwrap_or(DEFAULT)
38}
39
40fn report_max_rows() -> usize {
42 const DEFAULT: usize = 10_000;
43 std::env::var("ARCHITECT_REPORT_MAX_ROWS")
44 .ok()
45 .and_then(|v| v.parse::<usize>().ok())
46 .filter(|&n| n > 0)
47 .unwrap_or(DEFAULT)
48}
49
50fn report_role() -> Option<String> {
52 std::env::var("ARCHITECT_REPORT_ROLE")
53 .ok()
54 .map(|s| s.trim().to_string())
55 .filter(|s| !s.is_empty())
56}
57
58fn report_cache_enabled() -> bool {
60 std::env::var("ARCHITECT_REPORT_CACHE")
61 .map(|v| {
62 matches!(
63 v.trim().to_ascii_lowercase().as_str(),
64 "1" | "true" | "yes" | "on"
65 )
66 })
67 .unwrap_or(false)
68}
69
70fn report_cache_default_ttl() -> i64 {
73 const DEFAULT: i64 = 300;
74 std::env::var("ARCHITECT_REPORT_CACHE_TTL_SECS")
75 .ok()
76 .and_then(|v| v.parse::<i64>().ok())
77 .filter(|&n| n >= 0)
78 .unwrap_or(DEFAULT)
79}
80
81fn effective_cache_ttl(report: &ResolvedReport) -> Option<i64> {
84 if !report_cache_enabled() {
85 return None;
86 }
87 let ttl = report
88 .cache_ttl_secs
89 .unwrap_or_else(report_cache_default_ttl);
90 if ttl > 0 {
91 Some(ttl)
92 } else {
93 None
94 }
95}
96
97fn fnv1a_hex(s: &str) -> String {
101 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
102 for b in s.as_bytes() {
103 hash ^= *b as u64;
104 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
105 }
106 format!("{:016x}", hash)
107}
108
109fn canonical_params(params: &HashMap<String, Value>) -> String {
111 let sorted: std::collections::BTreeMap<&String, &Value> = params.iter().collect();
112 serde_json::to_string(&sorted).unwrap_or_default()
113}
114
115fn cache_key_for(
118 report: &ResolvedReport,
119 tenant_id: &str,
120 params: &HashMap<String, Value>,
121) -> String {
122 let material = format!(
123 "{}\u{0}{}\u{0}{}\u{0}{}\u{0}{}",
124 report.package_id,
125 tenant_id,
126 report.id,
127 report.sql,
128 canonical_params(params),
129 );
130 fnv1a_hex(&material)
131}
132
133#[derive(Deserialize, Default)]
134pub struct RunReportRequest {
135 #[serde(default)]
136 pub params: HashMap<String, Value>,
137}
138
139fn require_platform_admin(
142 state: &AppState,
143 tenant_id_opt: &Option<String>,
144) -> Result<(), AppError> {
145 let tenant_id = tenant_id_opt
146 .as_deref()
147 .filter(|s| !s.is_empty())
148 .ok_or_else(|| AppError::BadRequest("X-Tenant-ID header is required".into()))?;
149 state
150 .tenant_registry
151 .get(tenant_id)
152 .ok_or_else(|| AppError::NotFound(format!("tenant not found: {}", tenant_id)))?;
153 if tenant_id != crate::tenant::platform_tenant_id() {
154 return Err(AppError::Forbidden(
155 "report registration is restricted to the Platform Admin tenant".into(),
156 ));
157 }
158 Ok(())
159}
160
161async fn begin_readonly_tx(
166 state: &AppState,
167 ctx: &TenantContext,
168) -> Result<crate::db::pool::DbTransaction, AppError> {
169 let pool = ctx.migration_pool();
170 let mut tx = pool.begin().await?;
171 if let Some(sql) = state.dialect.set_read_only_sql() {
172 sqlx::query(&sql).execute(&mut *tx).await?;
173 }
174 if let Some(sql) = state.dialect.set_statement_timeout_sql(report_timeout_ms()) {
175 sqlx::query(&sql).execute(&mut *tx).await?;
176 }
177 if let Some(role) = report_role() {
178 if let Some(sql) = state.dialect.set_role_sql(&role) {
179 sqlx::query(&sql).execute(&mut *tx).await?;
180 }
181 }
182 if let TenantContext::Rls { tenant_id, .. } = ctx {
183 if let Some(sql) = state.dialect.set_tenant_session_sql(tenant_id) {
184 sqlx::query(&sql).execute(&mut *tx).await?;
185 }
186 }
187 Ok(tx)
188}
189
190fn lookup_report(state: &AppState, report_id: &str) -> Result<ResolvedReport, AppError> {
192 let guard = state
193 .model
194 .read()
195 .map_err(|_| AppError::BadRequest("state lock".into()))?;
196 guard
197 .report(report_id)
198 .cloned()
199 .ok_or_else(|| AppError::NotFound(format!("report not found: {}", report_id)))
200}
201
202fn report_metadata(r: &ResolvedReport) -> Value {
204 let params: Vec<Value> = r
205 .param_order
206 .iter()
207 .map(|name| {
208 let rule = r.rules.get(name);
209 json!({
210 "name": name,
211 "required": rule.and_then(|x| x.required).unwrap_or(false),
212 "default": r.defaults.get(name).cloned().unwrap_or(Value::Null),
213 "db_type": r.casts.get(name).cloned(),
214 })
215 })
216 .collect();
217 json!({
218 "id": r.id,
219 "name": r.name,
220 "description": r.description,
221 "schemas": r.schemas,
222 "params": params,
223 "cache_ttl_secs": r.cache_ttl_secs,
224 })
225}
226
227fn cache_hit_response(
231 envelope: &Value,
232 report: &ResolvedReport,
233 effective_tenant: &str,
234 params: &HashMap<String, Value>,
235) -> Option<Value> {
236 let expires_at = envelope.get("expires_at").and_then(Value::as_str)?;
237 let expires = chrono::DateTime::parse_from_rfc3339(expires_at).ok()?;
238 if expires <= chrono::Utc::now() {
239 return None;
240 }
241 if envelope.get("report_id").and_then(Value::as_str) != Some(report.id.as_str()) {
242 return None;
243 }
244 if envelope.get("tenant_id").and_then(Value::as_str) != Some(effective_tenant) {
245 return None;
246 }
247 if envelope.get("params").and_then(Value::as_str) != Some(canonical_params(params).as_str()) {
248 return None;
249 }
250 let result = envelope
251 .get("result")
252 .cloned()
253 .unwrap_or(Value::Array(vec![]));
254 let count = envelope
255 .get("row_count")
256 .and_then(Value::as_i64)
257 .unwrap_or(0);
258 let truncated = envelope
259 .get("truncated")
260 .and_then(Value::as_bool)
261 .unwrap_or(false);
262 Some(json!({
263 "data": result,
264 "meta": { "count": count, "report": report.id, "truncated": truncated, "cached": true },
265 }))
266}
267
268pub async fn run_report(
270 Path(report_id): Path<String>,
271 TenantId(tenant_id_opt): TenantId,
272 ActAsTenant(act_as_opt): ActAsTenant,
273 UserId(user_id_opt): UserId,
274 State(state): State<AppState>,
275 body: Option<Json<RunReportRequest>>,
276) -> Result<impl IntoResponse, AppError> {
277 let req = body.map(|Json(b)| b).unwrap_or_default();
278
279 let ctx = resolve_tenant_context(
280 &state,
281 tenant_id_opt.as_deref(),
282 act_as_opt.as_deref(),
283 None,
284 )
285 .await?;
286
287 let report = lookup_report(&state, &report_id)?;
288
289 crate::authrs::check_report_permission_opt(
290 &state.authrs_client,
291 tenant_id_opt.as_deref(),
292 user_id_opt.as_deref(),
293 &report,
294 "run",
295 )
296 .await?;
297
298 let mut params = req.params;
300 for (name, default) in &report.defaults {
301 params
302 .entry(name.clone())
303 .or_insert_with(|| default.clone());
304 }
305 RequestValidator::validate(¶ms, &report.rules)?;
306
307 let effective_tenant = act_as_opt
309 .as_deref()
310 .filter(|s| !s.is_empty())
311 .or(tenant_id_opt.as_deref())
312 .unwrap_or("")
313 .to_string();
314
315 let ttl = effective_cache_ttl(&report);
317 let cache_key = ttl.map(|_| cache_key_for(&report, &effective_tenant, ¶ms));
318 if let Some(key) = &cache_key {
319 if let Some(env) = crate::store::report_cache_get(
320 ctx.config_pool(),
321 &effective_tenant,
322 &report.package_id,
323 key,
324 )
325 .await?
326 {
327 if let Some(resp) = cache_hit_response(&env, &report, &effective_tenant, ¶ms) {
328 return Ok((StatusCode::OK, Json(resp)));
329 }
330 }
331 }
332
333 let binds: Vec<Value> = report
335 .param_order
336 .iter()
337 .map(|name| params.get(name).cloned().unwrap_or(Value::Null))
338 .collect();
339
340 let max_rows = report_max_rows();
343 let inner = report.sql.trim().trim_end_matches(';');
344 let wrapped = format!(
345 "SELECT * FROM ({}) AS _report LIMIT {}",
346 inner,
347 max_rows + 1
348 );
349
350 let mut tx = begin_readonly_tx(&state, &ctx).await?;
351 let mut rows = {
352 let mut exec = TenantExecutor::conn(&mut tx, state.dialect.as_ref());
353 CrudService::run_readonly_query(&mut exec, &wrapped, &binds).await?
354 };
355 drop(tx);
357
358 let truncated = rows.len() > max_rows;
359 if truncated {
360 rows.truncate(max_rows);
361 }
362 for row in &mut rows {
363 value_keys_to_camel_case(row);
364 }
365 let count = rows.len();
366
367 if let (Some(key), Some(ttl)) = (&cache_key, ttl) {
369 let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl);
370 let envelope = json!({
371 "expires_at": expires_at.to_rfc3339(),
372 "report_id": report.id,
373 "tenant_id": effective_tenant,
374 "params": canonical_params(¶ms),
375 "result": rows,
376 "row_count": count,
377 "truncated": truncated,
378 });
379 if let Err(e) = crate::store::report_cache_put(
380 ctx.config_pool(),
381 state.dialect.as_ref(),
382 &effective_tenant,
383 &report.package_id,
384 key,
385 &envelope,
386 )
387 .await
388 {
389 tracing::warn!(report = %report.id, error = %e, "report cache write failed");
390 }
391 }
392
393 Ok((
394 StatusCode::OK,
395 Json(json!({
396 "data": rows,
397 "meta": { "count": count, "report": report.id, "truncated": truncated, "cached": false },
398 })),
399 ))
400}
401
402pub async fn list_reports(
404 TenantId(tenant_id_opt): TenantId,
405 State(state): State<AppState>,
406) -> Result<impl IntoResponse, AppError> {
407 resolve_tenant_context(&state, tenant_id_opt.as_deref(), None, None).await?;
409 let guard = state
410 .model
411 .read()
412 .map_err(|_| AppError::BadRequest("state lock".into()))?;
413 let mut data: Vec<Value> = guard.reports.values().map(report_metadata).collect();
414 data.sort_by(|a, b| {
415 a["id"]
416 .as_str()
417 .unwrap_or("")
418 .cmp(b["id"].as_str().unwrap_or(""))
419 });
420 let count = data.len();
421 Ok((
422 StatusCode::OK,
423 Json(json!({ "data": data, "meta": { "count": count } })),
424 ))
425}
426
427pub async fn get_report(
429 Path(report_id): Path<String>,
430 TenantId(tenant_id_opt): TenantId,
431 State(state): State<AppState>,
432) -> Result<impl IntoResponse, AppError> {
433 resolve_tenant_context(&state, tenant_id_opt.as_deref(), None, None).await?;
434 let report = lookup_report(&state, &report_id)?;
435 Ok((
436 StatusCode::OK,
437 Json(json!({ "data": report_metadata(&report) })),
438 ))
439}
440
441pub async fn get_reports_config(
443 TenantId(tenant_id_opt): TenantId,
444 State(state): State<AppState>,
445) -> Result<impl IntoResponse, AppError> {
446 require_platform_admin(&state, &tenant_id_opt)?;
447 let out = get_config(&state.pool, "reports", DEFAULT_PACKAGE_ID).await?;
448 let count = out.len();
449 Ok((
450 StatusCode::OK,
451 Json(json!({ "data": out, "meta": { "count": count } })),
452 ))
453}
454
455async fn validate_reports(
457 state: &AppState,
458 tenant_id_opt: &Option<String>,
459 bodies: &[Value],
460) -> Result<Vec<ResolvedReport>, AppError> {
461 let cfgs: Vec<ReportConfig> = serde_json::from_value(Value::Array(bodies.to_vec()))
462 .map_err(|e| AppError::BadRequest(format!("invalid reports: {}", e)))?;
463 let compiled: Vec<ResolvedReport> = cfgs
464 .iter()
465 .map(compile_report)
466 .collect::<Result<_, _>>()
467 .map_err(AppError::Config)?;
468
469 let needs_explain = compiled.iter().any(|r| r.validate_on_register);
470 if needs_explain {
471 let ctx = resolve_tenant_context(state, tenant_id_opt.as_deref(), None, None).await?;
472 for r in &compiled {
473 if !r.validate_on_register {
474 continue;
475 }
476 let mut tx = begin_readonly_tx(state, &ctx).await?;
477 let explain = format!("EXPLAIN {}", r.sql.trim().trim_end_matches(';'));
478 let nulls = vec![Value::Null; r.param_order.len()];
479 let mut exec = TenantExecutor::conn(&mut tx, state.dialect.as_ref());
480 CrudService::run_readonly_query(&mut exec, &explain, &nulls)
481 .await
482 .map_err(|e| {
483 AppError::Validation(format!("report '{}' failed validation: {}", r.id, e))
484 })?;
485 }
486 }
487 Ok(compiled)
488}
489
490pub async fn post_reports(
492 TenantId(tenant_id_opt): TenantId,
493 State(state): State<AppState>,
494 Json(body): Json<Vec<Value>>,
495) -> Result<impl IntoResponse, AppError> {
496 require_platform_admin(&state, &tenant_id_opt)?;
497 validate_reports(&state, &tenant_id_opt, &body).await?;
498
499 let (out, num) = replace_config(
500 &state.pool,
501 "reports",
502 body,
503 false,
504 DEFAULT_PACKAGE_ID,
505 None,
506 )
507 .await?;
508 if num > 0 {
509 reload_model(&state).await?;
510 }
511 let count = out.len();
512 Ok((
513 StatusCode::OK,
514 Json(json!({ "data": out, "meta": { "count": count } })),
515 ))
516}
517
518pub async fn put_report_by_id(
521 Path(report_id): Path<String>,
522 TenantId(tenant_id_opt): TenantId,
523 State(state): State<AppState>,
524 Json(mut body): Json<Value>,
525) -> Result<impl IntoResponse, AppError> {
526 require_platform_admin(&state, &tenant_id_opt)?;
527
528 let obj = body
529 .as_object_mut()
530 .ok_or_else(|| AppError::BadRequest("report body must be a JSON object".into()))?;
531 obj.insert("id".into(), Value::String(report_id.clone()));
533
534 validate_reports(&state, &tenant_id_opt, std::slice::from_ref(&body)).await?;
536
537 let current = get_config(&state.pool, "reports", DEFAULT_PACKAGE_ID).await?;
539 let mut merged: Vec<Value> = current
540 .into_iter()
541 .filter(|r| r.get("id").and_then(Value::as_str) != Some(report_id.as_str()))
542 .collect();
543 merged.push(body.clone());
544
545 let (_out, num) = replace_config(
546 &state.pool,
547 "reports",
548 merged,
549 false,
550 DEFAULT_PACKAGE_ID,
551 None,
552 )
553 .await?;
554 if num > 0 {
555 reload_model(&state).await?;
556 }
557 Ok((StatusCode::OK, Json(json!({ "data": body }))))
558}
559
560pub async fn delete_report_by_id(
562 Path(report_id): Path<String>,
563 TenantId(tenant_id_opt): TenantId,
564 State(state): State<AppState>,
565) -> Result<impl IntoResponse, AppError> {
566 require_platform_admin(&state, &tenant_id_opt)?;
567
568 let current = get_config(&state.pool, "reports", DEFAULT_PACKAGE_ID).await?;
569 let existed = current
570 .iter()
571 .any(|r| r.get("id").and_then(Value::as_str) == Some(report_id.as_str()));
572 if !existed {
573 return Err(AppError::NotFound(format!(
574 "report not found: {}",
575 report_id
576 )));
577 }
578 let merged: Vec<Value> = current
579 .into_iter()
580 .filter(|r| r.get("id").and_then(Value::as_str) != Some(report_id.as_str()))
581 .collect();
582
583 let (_out, num) = replace_config(
584 &state.pool,
585 "reports",
586 merged,
587 false,
588 DEFAULT_PACKAGE_ID,
589 None,
590 )
591 .await?;
592 if num > 0 {
593 reload_model(&state).await?;
594 }
595 Ok((
596 StatusCode::OK,
597 Json(json!({ "data": { "id": report_id, "deleted": true } })),
598 ))
599}