fraiseql_server/routes/api/usage.rs
1//! Usage statistics API endpoint.
2//!
3//! Exposes in-memory mutation counters accumulated by the `MutationAuditLayer`
4//! tracing subscriber. Counters are keyed by `(tenant_id, period, entity_type)`
5//! and reset to zero on process restart.
6//!
7//! ## Endpoint
8//!
9//! ```text
10//! GET /api/v1/admin/usage?tenant_id=<str>&period=<YYYY-MM>
11//! ```
12//!
13//! Protected by the admin bearer token (same as all other admin read routes).
14//!
15//! ## Response
16//!
17//! ```json
18//! {
19//! "tenant_id": "acme",
20//! "period": "2026-05",
21//! "usage": {
22//! "mutations": { "User": 42, "Order": 7 }
23//! }
24//! }
25//! ```
26//!
27//! Unknown `tenant_id` / `period` combinations return 200 with
28//! `"usage": { "mutations": {} }` — never 404.
29//!
30//! Invalid `period` (not `YYYY-MM`) returns 400 with
31//! `{"error": "invalid period format"}`.
32
33use axum::{
34 Json,
35 extract::{Query, State},
36 http::StatusCode,
37};
38use fraiseql_core::db::traits::DatabaseAdapter;
39use serde::{Deserialize, Serialize};
40
41use crate::{
42 routes::graphql::AppState,
43 usage::aggregator::{UsageSummary, validate_period},
44};
45
46// ── Query parameters ───────────────────────────────────────────────────────
47
48/// Query parameters for the usage endpoint.
49#[non_exhaustive]
50#[derive(Debug, Deserialize)]
51pub struct UsageQueryParams {
52 /// Tenant identifier to query.
53 pub tenant_id: String,
54 /// Billing period in `YYYY-MM` format (e.g. `"2026-05"`).
55 pub period: String,
56}
57
58// ── Response types ─────────────────────────────────────────────────────────
59
60/// Successful usage query response.
61#[non_exhaustive]
62#[derive(Debug, Serialize)]
63pub struct UsageResponse {
64 /// The queried tenant identifier.
65 pub tenant_id: String,
66 /// The queried period (`YYYY-MM`).
67 pub period: String,
68 /// Mutation counts for the queried period.
69 pub usage: UsageSummary,
70}
71
72// ── Handler ────────────────────────────────────────────────────────────────
73
74/// Query mutation usage statistics for a tenant and period.
75///
76/// Returns 400 when `period` is not a valid `YYYY-MM` string. Returns 200
77/// with empty `mutations` for unknown tenant/period combinations.
78///
79/// # Errors
80///
81/// Returns `(400, {"error": "invalid period format"})` when the `period`
82/// query parameter is not in `YYYY-MM` format.
83pub async fn usage_handler<A: DatabaseAdapter>(
84 State(state): State<AppState<A>>,
85 Query(params): Query<UsageQueryParams>,
86) -> Result<Json<UsageResponse>, (StatusCode, Json<serde_json::Value>)> {
87 if !validate_period(¶ms.period) {
88 return Err((
89 StatusCode::BAD_REQUEST,
90 Json(serde_json::json!({"error": "invalid period format"})),
91 ));
92 }
93
94 let usage = state.usage.query(¶ms.tenant_id, ¶ms.period);
95
96 Ok(Json(UsageResponse {
97 tenant_id: params.tenant_id,
98 period: params.period,
99 usage,
100 }))
101}
102
103// ── Tests ──────────────────────────────────────────────────────────────────