use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{FromRequest, State};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use http::HeaderMap;
use serde_json::{Value, json};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use cratefield_core::{
Clock, Json, ModuleContext, Problem, ProblemDef, RateLimiter, Scope, SystemClock, client_ip,
rate_limit_keys, rate_limited, require_admin,
};
use crate::consent;
use crate::payload::{Batch, Rejection, Vocabulary};
use crate::store;
pub(crate) const EVENT_RECORDED: &str = "telemetry.recorded";
pub(crate) const SCHEMA_UNSUPPORTED: ProblemDef = ProblemDef {
slug: "telemetry-schema-unsupported",
status: axum::http::StatusCode::BAD_REQUEST,
title: "Telemetry schema not supported",
description: "The batch names a payload schema version this collector does not parse.",
};
pub(crate) const PAYLOAD_REJECTED: ProblemDef = ProblemDef {
slug: "telemetry-payload-rejected",
status: axum::http::StatusCode::BAD_REQUEST,
title: "Telemetry payload rejected",
description: "The batch carries a value outside the closed grammar the notice route publishes.",
};
const NOT_JSON_DETAIL: &str = "the request body was not valid JSON, so no field in it was read";
const NOT_JSON_CONTENT_TYPE_DETAIL: &str =
"the request did not name a JSON content type, so its body was not read";
#[derive(Clone)]
pub struct Settings {
pub vocabulary: Vocabulary,
pub retention_days: u32,
pub opt_out_command: String,
pub status_command: String,
}
pub(crate) struct ModuleState {
pub ctx: Arc<ModuleContext>,
pub settings: Settings,
}
pub(crate) fn router(ctx: ModuleContext, settings: Settings) -> axum::Router {
let state = Arc::new(ModuleState {
ctx: Arc::new(ctx),
settings,
});
axum::Router::new()
.route("/events", post(record))
.route("/notice", get(notice))
.route("/admin/usage", get(admin_usage))
.with_state(state)
}
pub(crate) fn now_of(ctx: &ModuleContext) -> OffsetDateTime {
ctx.ports
.clock
.as_ref()
.map_or_else(|| SystemClock.now(), |clock| clock.now())
}
pub(crate) fn day_of(at: OffsetDateTime) -> String {
format!(
"{:04}-{:02}-{:02}",
at.year(),
u8::from(at.month()),
at.day()
)
}
fn rfc3339_seconds(at: OffsetDateTime) -> String {
at.replace_nanosecond(0)
.expect("truncation stays in range")
.format(&Rfc3339)
.unwrap_or_default()
}
fn accepted() -> Response {
(
axum::http::StatusCode::ACCEPTED,
Json(json!({ "ok": true })),
)
.into_response()
}
fn internal(scope: &Scope) -> Problem {
Problem::internal().instance(&scope.request_id)
}
fn rejection_problem(scope: &Scope, rejection: Rejection) -> Problem {
match rejection {
Rejection::SchemaUnsupported => {
Problem::new(&SCHEMA_UNSUPPORTED).instance(&scope.request_id)
}
other => Problem::new(&PAYLOAD_REJECTED)
.with_detail(other.to_string())
.instance(&scope.request_id),
}
}
fn not_json_problem(scope: &Scope) -> Problem {
Problem::new(&PAYLOAD_REJECTED)
.with_detail(NOT_JSON_DETAIL)
.instance(&scope.request_id)
}
async fn rate_limit(state: &ModuleState, headers: &HeaderMap) -> Option<Response> {
let limiter: Arc<dyn RateLimiter> = state.ctx.ports.rate_limiter.clone()?;
let ip = client_ip(headers);
for key in rate_limit_keys(ip.as_deref(), None) {
match limiter.limit(&key).await {
Ok(decision) if decision.ok => {}
Ok(decision) => {
return Some(rate_limited(&decision));
}
Err(err) => {
tracing::warn!(error = %err, key = %key, "rate limiter unavailable; allowing");
}
}
}
None
}
struct RawBody(Bytes);
impl<S: Send + Sync> FromRequest<S> for RawBody {
type Rejection = Problem;
async fn from_request(
request: axum::extract::Request,
state: &S,
) -> Result<Self, Self::Rejection> {
let instance = request
.extensions()
.get::<Scope>()
.map(|scope| scope.request_id.clone());
if !json_content_type(request.headers()) {
let problem = Problem::new(&PAYLOAD_REJECTED).with_detail(NOT_JSON_CONTENT_TYPE_DETAIL);
return Err(match instance {
Some(request_id) => problem.instance(&request_id),
None => problem,
});
}
if let Ok(bytes) = Bytes::from_request(request, state).await {
Ok(Self(bytes))
} else {
let mut problem = Problem::request_too_large();
if let Some(instance) = instance {
problem = problem.instance(&instance);
}
Err(problem)
}
}
}
fn json_content_type(headers: &HeaderMap) -> bool {
headers
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<mime::Mime>().ok())
.is_some_and(|mime| {
mime.type_() == "application"
&& (mime.subtype() == "json" || mime.suffix().is_some_and(|name| name == "json"))
})
}
async fn record(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
RawBody(bytes): RawBody,
) -> Result<Response, Problem> {
if let Some(denied) = rate_limit(&state, &headers).await {
return Ok(denied);
}
let body: Value = serde_json::from_slice(&bytes).map_err(|_| not_json_problem(&scope))?;
let batch = Batch::parse(&body, &state.settings.vocabulary)
.map_err(|rejection| rejection_problem(&scope, rejection))?;
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let now = now_of(&state.ctx);
store::record_batch(&*db, &batch, &day_of(now), &rfc3339_seconds(now)).await?;
state.ctx.events.emit_in(
&scope,
EVENT_RECORDED,
json!({"events": batch.events.len(), "modules": batch.modules.len()}),
);
Ok(accepted())
}
async fn notice(State(state): State<Arc<ModuleState>>) -> Json<Value> {
Json(json!({
"schema": crate::payload::SCHEMA,
"notice": consent::notice(&state.settings),
"opt_out_command": state.settings.opt_out_command,
"status_command": state.settings.status_command,
"events": state.settings.vocabulary.events,
"modules": state.settings.vocabulary.modules,
"fields": crate::payload::fields(&state.settings.vocabulary),
}))
}
async fn admin_usage(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
) -> Result<Response, Problem> {
require_admin(&*state.ctx.config, &headers)
.map_err(|problem| problem.instance(&scope.request_id))?;
let Some(db) = state.ctx.ports.db.clone() else {
return Err(internal(&scope));
};
let rows = store::usage(&*db).await?;
Ok(Json(json!({ "rows": rows })).into_response())
}