use std::sync::Arc;
use axum::{
Json,
extract::{Path, Query, State},
response::{IntoResponse, Response},
routing::{get, post},
};
use cratefield_core::{
Action, Audience, ModuleContext, Outcome, Problem, Scope, Surface, require_admin,
};
use http::{HeaderMap, header};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::Settings;
use crate::cache::ReadCache;
use crate::source;
use crate::store::{self, Release};
pub(crate) struct ModuleState {
pub ctx: Arc<ModuleContext>,
pub settings: Settings,
}
impl ModuleState {
pub(crate) fn resolved(&self) -> Settings {
source::resolved(&self.ctx, &self.settings)
}
}
pub(crate) fn router(ctx: Arc<ModuleContext>, settings: Settings) -> axum::Router {
let state = Arc::new(ModuleState { ctx, settings });
axum::Router::new()
.route("/", get(list_releases))
.route("/{version}", get(read_release))
.route("/admin/refresh", post(refresh_now))
.with_state(state)
}
fn internal(scope: &Scope) -> Problem {
Problem::internal().instance(&scope.request_id)
}
fn admin_gate(state: &ModuleState, headers: &HeaderMap, scope: &Scope) -> Result<(), Problem> {
require_admin(&*state.ctx.config, headers).map_err(|p| p.instance(&scope.request_id))
}
fn json_body(body: String) -> Response {
([(header::CONTENT_TYPE, "application/json")], body).into_response()
}
fn source_json(settings: &Settings) -> Value {
json!({
"kind": settings.kind(),
"repo": settings.repo.as_deref().unwrap_or(""),
})
}
fn rendering_json(settings: &Settings) -> Value {
json!({
"style": "original",
"locale": settings.locale,
"machine_generated": false,
})
}
fn release_json(release: &Release, settings: &Settings) -> Value {
json!({
"version": release.version,
"title": release.title,
"body": release.body,
"url": release.url,
"published_at": if release.published_at.is_empty() {
Value::Null
} else {
json!(release.published_at)
},
"prerelease": release.prerelease,
"rendering": rendering_json(settings),
})
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub(crate) struct ListQuery {
pub page: Option<String>,
pub per_page: Option<String>,
pub locale: Option<String>,
pub style: Option<String>,
}
const DEFAULT_PER_PAGE: u64 = 20;
const MAX_PER_PAGE: u64 = 100;
async fn list_releases(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Query(query): Query<ListQuery>,
) -> Result<Response, Problem> {
let settings = state.resolved();
let page = parse_count(query.page.as_deref(), "page", 1)?.max(1);
let per_page = parse_count(query.per_page.as_deref(), "per_page", DEFAULT_PER_PAGE)?
.clamp(1, MAX_PER_PAGE);
let offset = (page - 1).saturating_mul(per_page).min(i64::MAX as u64);
let fingerprint = format!(
"list:{}:{page}:{per_page}:{}:{}:{}",
settings.source_id(),
settings.locale,
query.locale.as_deref().unwrap_or(""),
query.style.as_deref().unwrap_or(""),
);
let cache = ReadCache::of(&state.ctx, settings.cache_ttl_secs);
if let Some(body) = cache.get(&fingerprint).await {
return Ok(json_body(body));
}
let db = state
.ctx
.ports
.db
.clone()
.ok_or_else(|| Problem::not_ready("the changelog module needs a database"))?;
let source_id = settings.source_id();
let total = store::count_releases(db.as_ref(), &source_id)
.await
.map_err(|_| internal(&scope))?;
let releases = store::list_releases(db.as_ref(), &source_id, per_page, offset)
.await
.map_err(|_| internal(&scope))?;
let refreshed_at = match store::source_state(db.as_ref(), &source_id).await {
Ok(Some(source)) if !source.last_refreshed_at.is_empty() => {
json!(source.last_refreshed_at)
}
_ => Value::Null,
};
let list: Vec<Value> = releases
.iter()
.map(|release| release_json(release, &settings))
.collect();
let body = json!({
"releases": list,
"page": page,
"per_page": per_page,
"total": total,
"source": source_json(&settings),
"refreshed_at": refreshed_at,
})
.to_string();
cache.put(&fingerprint, &body).await;
Ok(json_body(body))
}
async fn read_release(
scope: Scope,
State(state): State<Arc<ModuleState>>,
Path(version): Path<String>,
) -> Result<Response, Problem> {
let settings = state.resolved();
let fingerprint = format!(
"release:{}:{}:{version}",
settings.source_id(),
settings.locale,
);
let cache = ReadCache::of(&state.ctx, settings.cache_ttl_secs);
if let Some(body) = cache.get(&fingerprint).await {
return Ok(json_body(body));
}
let db = state
.ctx
.ports
.db
.clone()
.ok_or_else(|| Problem::not_ready("the changelog module needs a database"))?;
let Some(release) = store::find_release(db.as_ref(), &settings.source_id(), &version)
.await
.map_err(|_| internal(&scope))?
else {
return Err(Problem::not_found().instance(&scope.request_id));
};
let body = release_json(&release, &settings).to_string();
cache.put(&fingerprint, &body).await;
Ok(json_body(body))
}
fn parse_count(raw: Option<&str>, name: &str, default: u64) -> Result<u64, Problem> {
match raw {
None | Some("") => Ok(default),
Some(value) => value.trim().parse::<u64>().map_err(|_| {
Problem::validation_failed(format!("{name} must be a positive integer, got {value:?}"))
}),
}
}
async fn refresh_now(
scope: Scope,
State(state): State<Arc<ModuleState>>,
headers: HeaderMap,
) -> Result<Response, Problem> {
admin_gate(&state, &headers, &scope)?;
let settings = state.resolved();
let report = source::refresh(&state.ctx, &settings)
.await
.map_err(|problem| problem.instance(&scope.request_id))?;
Ok(Json(json!({
"ok": true,
"fetched": report.fetched,
"inserted": report.inserted,
"updated": report.updated,
"unchanged": report.unchanged,
"skipped": report.skipped,
"removed": report.removed,
"not_modified": report.not_modified,
"complete": report.complete,
}))
.into_response())
}
pub(crate) fn surface() -> Surface {
Surface::new()
.action(
Action::get("releases", "/")
.audience(Audience::Public)
.input::<ListQuery>()
.outcome(Outcome::Json),
)
.action(
Action::get("release", "/{version}")
.audience(Audience::Public)
.outcome(Outcome::Json),
)
.action(
Action::post("refresh", "/admin/refresh")
.audience(Audience::Admin)
.outcome(Outcome::Json),
)
}
#[cfg(test)]
mod tests {
use super::*;
use http::StatusCode;
#[test]
fn counts_default_and_refuse_nonsense() {
assert_eq!(parse_count(None, "page", 1).unwrap(), 1);
assert_eq!(parse_count(Some(""), "page", 7).unwrap(), 7);
assert_eq!(parse_count(Some(" 4 "), "page", 1).unwrap(), 4);
let problem = parse_count(Some("soon"), "page", 1).unwrap_err();
assert_eq!(problem.status, StatusCode::BAD_REQUEST);
}
}