use crate::api::{
self, AppendRequest, ElementJson, ErrorResponse, StatsJson, UpdateRequest,
};
use crate::date_parse;
use crate::error::MpsError;
use crate::ref_resolver::RefResolver;
use crate::store::Store;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::routing::{delete as route_delete, get, patch};
use axum::{Json, Router};
const UI_HTML: &str = include_str!("../assets/index.html");
use chrono::{Local, NaiveDate};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use crate::config::Config;
pub struct AppState {
pub config: Config,
#[allow(dead_code)]
pub config_path: PathBuf,
}
type ApiResult<T> = Result<T, (StatusCode, Json<ErrorResponse>)>;
fn api_err(code: u16, msg: impl ToString) -> (StatusCode, Json<ErrorResponse>) {
let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
(status, Json(ErrorResponse { error: msg.to_string() }))
}
fn mps_err(e: MpsError) -> (StatusCode, Json<ErrorResponse>) {
let code = match &e {
MpsError::DateParseError(_) | MpsError::ConfigInvalid(_) | MpsError::TimeParse(_) => {
StatusCode::BAD_REQUEST
}
MpsError::ConfigNotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(code, Json(ErrorResponse { error: e.to_string() }))
}
fn today() -> NaiveDate {
Local::now().date_naive()
}
fn parse_date_param(s: &str) -> Result<NaiveDate, (StatusCode, Json<ErrorResponse>)> {
date_parse::parse_date(s).map_err(mps_err)
}
#[derive(Debug, Deserialize)]
pub struct ListParams {
pub date: Option<String>,
pub r#type: Option<String>,
pub tag: Option<String>,
pub since: Option<String>,
#[serde(default)]
pub all: bool,
}
#[derive(Debug, Deserialize)]
pub struct DeleteParams {
pub date: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SearchParams {
pub q: Option<String>,
pub r#type: Option<String>,
pub tag: Option<String>,
pub since: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct StatsParams {
pub date: Option<String>,
pub since: Option<String>,
#[serde(default)]
pub all: bool,
}
#[derive(Debug, Deserialize)]
pub struct ExportParams {
pub format: Option<String>,
pub date: Option<String>,
pub since: Option<String>,
pub r#type: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TagsParams {
pub date: Option<String>,
#[serde(default)]
pub all: bool,
pub r#type: Option<String>,
}
async fn auth_middleware(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
mut req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let token = &state.config.serve.token;
if !token.is_empty() {
let auth_header = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let expected = format!("Bearer {}", token);
if auth_header != expected {
let body = Json(ErrorResponse { error: "unauthorized".into() });
return (StatusCode::UNAUTHORIZED, body).into_response();
}
}
req.extensions_mut().insert(AuthChecked);
next.run(req).await
}
#[derive(Clone)]
struct AuthChecked;
pub fn build_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/", get(ui_handler))
.route("/health", get(health_handler))
.route("/elements", get(list_elements).post(append_element))
.route(
"/elements/:element_ref",
get(get_element).patch(update_element).delete(delete_element),
)
.route("/search", get(search_handler))
.route("/stats", get(stats_handler))
.route("/export", get(export_handler))
.route("/tags", get(tags_handler))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
auth_middleware,
))
.layer(CorsLayer::permissive())
.with_state(state)
}
pub async fn run(config: Config, config_path: PathBuf) -> anyhow::Result<()> {
let addr = format!("{}:{}", config.serve.host, config.serve.port);
let state = Arc::new(AppState { config, config_path });
let listener = tokio::net::TcpListener::bind(&addr).await?;
println!(" mps serve http://{}", addr);
axum::serve(listener, build_router(state)).await?;
Ok(())
}
async fn ui_handler() -> impl IntoResponse {
axum::response::Response::builder()
.status(200)
.header("Content-Type", "text/html; charset=utf-8")
.body(axum::body::Body::from(UI_HTML))
.unwrap()
}
async fn health_handler() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
}))
}
async fn list_elements(
State(state): State<Arc<AppState>>,
Query(params): Query<ListParams>,
) -> ApiResult<Json<Vec<ElementJson>>> {
let config = state.config.clone();
let date = match ¶ms.date {
Some(d) => parse_date_param(d)?,
None => today(),
};
let since = match ¶ms.since {
Some(s) => Some(parse_date_param(s)?),
None => None,
};
tokio::task::spawn_blocking(move || {
let store = Store::new(&config.storage_dir);
let files = if params.all {
store.all_files().map_err(mps_err)?
} else if let Some(sd) = since {
store.files_since(sd).map_err(mps_err)?
} else {
store.find_files(date)
};
let mut results = Vec::new();
for file in &files {
let date_str = file
.file_name()
.and_then(|n| n.to_str())
.map(|n| n[..8].to_string())
.unwrap_or_default();
let elements = crate::parser::parse_file(file).map_err(mps_err)?;
let resolver = RefResolver::new(&elements);
for (epoch_ref, el) in &elements {
if el.is_mps_group() || el.is_unknown() {
continue;
}
if let Some(ref tf) = params.r#type {
if el.sign() != tf.as_str() {
continue;
}
}
if let Some(ref tag) = params.tag {
if !el.tags().iter().any(|t| t == tag) {
continue;
}
}
results.push(api::element_to_json(el, epoch_ref, &date_str, &resolver));
}
}
Ok(Json(results))
})
.await
.map_err(|_| api_err(500, "internal error"))?
}
async fn get_element(
State(state): State<Arc<AppState>>,
Path(element_ref): Path<String>,
Query(params): Query<DeleteParams>,
) -> ApiResult<Json<ElementJson>> {
let config = state.config.clone();
tokio::task::spawn_blocking(move || {
let date = match ¶ms.date {
Some(d) => parse_date_param(d)?,
None => today(),
};
let store = Store::new(&config.storage_dir);
let files = store.find_files(date);
for file in &files {
let date_str = file
.file_name()
.and_then(|n| n.to_str())
.map(|n| n[..8].to_string())
.unwrap_or_default();
let elements = crate::parser::parse_file(file).map_err(mps_err)?;
let resolver = RefResolver::new(&elements);
for (epoch_ref, el) in &elements {
if el.is_mps_group() || el.is_unknown() {
continue;
}
let human = resolver.to_human(epoch_ref).map(|s| s.to_string());
if epoch_ref == &element_ref || human.as_deref() == Some(element_ref.as_str()) {
return Ok(Json(api::element_to_json(el, epoch_ref, &date_str, &resolver)));
}
}
}
let all_files = store.all_files().map_err(mps_err)?;
for file in &all_files {
let date_str = file
.file_name()
.and_then(|n| n.to_str())
.map(|n| n[..8].to_string())
.unwrap_or_default();
let elements = crate::parser::parse_file(file).map_err(mps_err)?;
let resolver = RefResolver::new(&elements);
for (epoch_ref, el) in &elements {
if el.is_mps_group() || el.is_unknown() {
continue;
}
if epoch_ref == &element_ref {
return Ok(Json(api::element_to_json(el, epoch_ref, &date_str, &resolver)));
}
}
}
Err(api_err(404, format!("element '{}' not found", element_ref)))
})
.await
.map_err(|_| api_err(500, "internal error"))?
}
async fn append_element(
State(state): State<Arc<AppState>>,
Json(body): Json<AppendRequest>,
) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
let config = state.config.clone();
tokio::task::spawn_blocking(move || {
let kind = body.element_type.to_lowercase();
const VALID: &[&str] = &["task", "note", "log", "reminder", "character"];
if !VALID.contains(&kind.as_str()) {
return Err(api_err(
400,
format!("unknown type '{}'; valid: {}", kind, VALID.join(", ")),
));
}
let date = match &body.date {
Some(d) => parse_date_param(d)?,
None => today(),
};
let mut attrs: Vec<(&str, String)> = Vec::new();
if let Some(ref s) = body.status {
attrs.push(("status", s.clone()));
}
if let Some(ref a) = body.at {
attrs.push(("at", a.clone()));
}
if let Some(ref s) = body.start {
attrs.push(("start", s.clone()));
}
if let Some(ref e) = body.end {
attrs.push(("end", e.clone()));
}
if let Some(ref n) = body.name {
attrs.push(("name", n.clone()));
}
let attrs_ref: Vec<(&str, &str)> =
attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
let store = Store::new(&config.storage_dir);
store
.append(&kind, &body.body, &body.tags, &attrs_ref, date)
.map_err(mps_err)?;
let path = store.find_or_create_path(date);
let elements = crate::parser::parse_file(&path).map_err(mps_err)?;
let resolver = RefResolver::new(&elements);
let epoch_ref = elements
.iter()
.filter(|(_, el)| el.sign() == kind && el.body_str().trim() == body.body.trim())
.map(|(k, _)| k.clone())
.last()
.unwrap_or_default();
let human_ref = resolver.to_human(&epoch_ref).map(|s| s.to_string());
Ok((
StatusCode::CREATED,
Json(serde_json::json!({ "ref": epoch_ref, "human_ref": human_ref })),
))
})
.await
.map_err(|_| api_err(500, "internal error"))?
}
async fn update_element(
State(state): State<Arc<AppState>>,
Path(element_ref): Path<String>,
Json(body): Json<UpdateRequest>,
) -> ApiResult<Json<serde_json::Value>> {
let config = state.config.clone();
tokio::task::spawn_blocking(move || {
let date = match &body.date {
Some(d) => parse_date_param(d)?,
None => today(),
};
let mut new_attrs: HashMap<String, String> = HashMap::new();
if let Some(ref s) = body.status {
new_attrs.insert("status".into(), s.clone());
}
if let Some(ref a) = body.at {
new_attrs.insert("at".into(), a.clone());
}
if let Some(ref s) = body.start {
new_attrs.insert("start".into(), s.clone());
}
if let Some(ref e) = body.end {
new_attrs.insert("end".into(), e.clone());
}
if let Some(ref n) = body.name {
new_attrs.insert("name".into(), n.clone());
}
let store = Store::new(&config.storage_dir);
if let Some(ref new_body) = body.body {
let ok = store
.replace_element_body(&element_ref, new_body, date)
.map_err(mps_err)?;
if !ok && new_attrs.is_empty() {
return Err(api_err(404, format!("element '{}' not found", element_ref)));
}
}
if !new_attrs.is_empty() {
let ok = store
.rewrite_element(&element_ref, &new_attrs, date)
.map_err(|e| match &e {
MpsError::ConfigNotFound(_) => api_err(404, e.to_string()),
_ => mps_err(e),
})?;
if !ok && body.body.is_none() {
return Err(api_err(404, format!("element '{}' not found", element_ref)));
}
}
if body.body.is_none() && new_attrs.is_empty() {
return Err(api_err(400, "no fields to update"));
}
Ok(Json(serde_json::json!({ "updated": true })))
})
.await
.map_err(|_| api_err(500, "internal error"))?
}
async fn delete_element(
State(state): State<Arc<AppState>>,
Path(element_ref): Path<String>,
Query(params): Query<DeleteParams>,
) -> ApiResult<Json<serde_json::Value>> {
let config = state.config.clone();
tokio::task::spawn_blocking(move || {
let date = match ¶ms.date {
Some(d) => parse_date_param(d)?,
None => today(),
};
let store = Store::new(&config.storage_dir);
let deleted = store
.delete_element(&element_ref, date)
.map_err(mps_err)?;
if !deleted {
return Err(api_err(404, format!("element '{}' not found", element_ref)));
}
Ok(Json(serde_json::json!({ "deleted": true })))
})
.await
.map_err(|_| api_err(500, "internal error"))?
}
async fn search_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<SearchParams>,
) -> ApiResult<Json<Vec<ElementJson>>> {
let config = state.config.clone();
tokio::task::spawn_blocking(move || {
let since = match ¶ms.since {
Some(s) => Some(parse_date_param(s)?),
None => None,
};
let query = params.q.as_deref().unwrap_or("");
let store = Store::new(&config.storage_dir);
let results = store
.search(
query,
params.r#type.as_deref(),
params.tag.as_deref(),
since,
)
.map_err(mps_err)?;
let mut out = Vec::new();
for sr in &results {
let elements = crate::parser::parse_file(&sr.file).map_err(mps_err)?;
let resolver = RefResolver::new(&elements);
out.push(api::element_to_json(
&sr.element,
&sr.epoch_ref,
&sr.date_str,
&resolver,
));
}
Ok(Json(out))
})
.await
.map_err(|_| api_err(500, "internal error"))?
}
async fn stats_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<StatsParams>,
) -> ApiResult<Json<StatsJson>> {
let config = state.config.clone();
tokio::task::spawn_blocking(move || {
let store = Store::new(&config.storage_dir);
let date = match ¶ms.date {
Some(d) => parse_date_param(d)?,
None => today(),
};
let since = match ¶ms.since {
Some(s) => Some(parse_date_param(s)?),
None => None,
};
let files = if params.all {
store.all_files().map_err(mps_err)?
} else if let Some(sd) = since {
store.files_since(sd).map_err(mps_err)?
} else {
store.find_files(date)
};
let mut date_elements = Vec::new();
for file in &files {
let date_str = file
.file_name()
.and_then(|n| n.to_str())
.map(|n| n[..8].to_string())
.unwrap_or_default();
let elements = crate::parser::parse_file(file).map_err(mps_err)?;
date_elements.push((date_str, elements));
}
Ok(Json(api::compute_stats(date_elements)))
})
.await
.map_err(|_| api_err(500, "internal error"))?
}
async fn export_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<ExportParams>,
) -> impl IntoResponse {
let config = state.config.clone();
let result = tokio::task::spawn_blocking(move || -> Result<(String, String), (StatusCode, Json<ErrorResponse>)> {
let date = match ¶ms.date {
Some(d) => parse_date_param(d)?,
None => today(),
};
let since = match ¶ms.since {
Some(s) => Some(parse_date_param(s)?),
None => None,
};
let fmt = params.format.as_deref().unwrap_or("json");
let store = Store::new(&config.storage_dir);
let files = if let Some(sd) = since {
store.files_since(sd).map_err(mps_err)?
} else {
store.find_files(date)
};
let mut all_json: Vec<serde_json::Value> = Vec::new();
for file in &files {
let date_str = file
.file_name()
.and_then(|n| n.to_str())
.map(|n| n[..8].to_string())
.unwrap_or_default();
let elements = crate::parser::parse_file(file).map_err(mps_err)?;
let resolver = RefResolver::new(&elements);
for (epoch_ref, el) in &elements {
if el.is_mps_group() || el.is_unknown() {
continue;
}
if let Some(ref tf) = params.r#type {
if el.sign() != tf.as_str() {
continue;
}
}
let ej = api::element_to_json(el, epoch_ref, &date_str, &resolver);
all_json.push(
serde_json::to_value(&ej).map_err(|e| api_err(500, e))?,
);
}
}
if fmt == "csv" {
let csv_out = export_to_csv(&all_json).map_err(|e| api_err(500, e))?;
Ok(("text/csv".into(), csv_out))
} else {
let json_str =
serde_json::to_string(&all_json).map_err(|e| api_err(500, e))?;
Ok(("application/json".into(), json_str))
}
})
.await;
match result {
Ok(Ok((content_type, body))) => axum::response::Response::builder()
.status(200)
.header("Content-Type", content_type)
.body(axum::body::Body::from(body))
.unwrap(),
Ok(Err((status, json))) => {
(status, json).into_response()
}
Err(_) => api_err(500, "internal error").into_response(),
}
}
fn export_to_csv(records: &[serde_json::Value]) -> Result<String, String> {
let mut wtr = csv::Writer::from_writer(vec![]);
let headers = ["ref", "human_ref", "date", "type", "tags", "body", "status", "at", "start", "end", "name"];
wtr.write_record(&headers).map_err(|e| e.to_string())?;
for rec in records {
let get = |key: &str| -> String {
match rec.get(key) {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(a)) => {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
}
_ => String::new(),
}
};
wtr.write_record(&[
get("ref"),
get("human_ref"),
get("date"),
get("type"),
get("tags"),
get("body"),
get("status"),
get("at"),
get("start"),
get("end"),
get("name"),
])
.map_err(|e| e.to_string())?;
}
wtr.into_inner()
.map(|v| String::from_utf8(v).unwrap_or_default())
.map_err(|e| e.to_string())
}
async fn tags_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<TagsParams>,
) -> ApiResult<Json<HashMap<String, usize>>> {
let config = state.config.clone();
tokio::task::spawn_blocking(move || {
let date = match ¶ms.date {
Some(d) => parse_date_param(d)?,
None => today(),
};
let store = Store::new(&config.storage_dir);
let files = if params.all {
store.all_files().map_err(mps_err)?
} else {
store.find_files(date)
};
let mut freq: HashMap<String, usize> = HashMap::new();
for file in &files {
let elements = crate::parser::parse_file(file).map_err(mps_err)?;
for (_, el) in &elements {
if el.is_mps_group() || el.is_unknown() {
continue;
}
if let Some(ref tf) = params.r#type {
if el.sign() != tf.as_str() {
continue;
}
}
for tag in el.tags() {
*freq.entry(tag.clone()).or_insert(0) += 1;
}
}
}
Ok(Json(freq))
})
.await
.map_err(|_| api_err(500, "internal error"))?
}