refactor(api): split Axum handlers into handler/service/repo modules
Keep the REST contract; move domain logic out of route files so checkout, fulfillment, and identity can be reused across customer, shop, and admin surfaces. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e457339847
commit
5e866d08f4
@@ -0,0 +1,106 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::Currency;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::service;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/currencies", get(list_currencies))
|
||||
.route("/currencies/convert", get(convert))
|
||||
.route(
|
||||
"/admin/currencies",
|
||||
get(list_all_currencies).post(upsert_currency),
|
||||
)
|
||||
.route("/admin/currencies/{code}/rate", put(set_rate))
|
||||
}
|
||||
|
||||
async fn list_currencies(State(state): State<AppState>) -> ApiResult<Json<Vec<Currency>>> {
|
||||
Ok(Json(service::load_currencies(&state, true).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConvertQuery {
|
||||
amount_minor: i64,
|
||||
from: String,
|
||||
to: String,
|
||||
}
|
||||
|
||||
async fn convert(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ConvertQuery>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let (amount_minor, currency) =
|
||||
service::convert(&state, q.amount_minor, &q.from, &q.to).await?;
|
||||
Ok(Json(json!({ "amount_minor": amount_minor, "currency": currency })))
|
||||
}
|
||||
|
||||
async fn list_all_currencies(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> ApiResult<Json<Vec<Currency>>> {
|
||||
auth.require_admin()?;
|
||||
Ok(Json(service::load_currencies(&state, false).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CurrencyBody {
|
||||
code: String,
|
||||
name: Value,
|
||||
symbol: String,
|
||||
exponent: i16,
|
||||
rate_to_base: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
async fn upsert_currency(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<CurrencyBody>,
|
||||
) -> ApiResult<Json<Currency>> {
|
||||
auth.require_admin()?;
|
||||
let rate: rust_decimal::Decimal = body
|
||||
.rate_to_base
|
||||
.parse()
|
||||
.map_err(|_| ApiError::BadRequest("rate_to_base must be numeric".into()))?;
|
||||
Ok(Json(
|
||||
service::upsert(
|
||||
&state,
|
||||
body.code,
|
||||
body.name,
|
||||
body.symbol,
|
||||
body.exponent,
|
||||
rate,
|
||||
body.enabled,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetRateBody {
|
||||
rate_to_base: String,
|
||||
}
|
||||
|
||||
async fn set_rate(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(code): Path<String>,
|
||||
Json(body): Json<SetRateBody>,
|
||||
) -> ApiResult<Json<Currency>> {
|
||||
auth.require_admin()?;
|
||||
let rate: rust_decimal::Decimal = body
|
||||
.rate_to_base
|
||||
.parse()
|
||||
.map_err(|_| ApiError::BadRequest("rate_to_base must be numeric".into()))?;
|
||||
Ok(Json(service::set_rate(&state, &code, rate).await?))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod handlers;
|
||||
pub mod service;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
handlers::router()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use crate::error::ApiResult;
|
||||
use crate::models::Currency;
|
||||
use crate::money::convert_minor;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn load_currencies(state: &AppState, enabled_only: bool) -> ApiResult<Vec<Currency>> {
|
||||
let rows = if enabled_only {
|
||||
sqlx::query_as::<_, Currency>(
|
||||
"SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled
|
||||
FROM currencies WHERE enabled = TRUE ORDER BY code",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, Currency>(
|
||||
"SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled
|
||||
FROM currencies ORDER BY code",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
};
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn convert(
|
||||
state: &AppState,
|
||||
amount_minor: i64,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> ApiResult<(i64, String)> {
|
||||
let currencies = load_currencies(state, true).await?;
|
||||
let from_c = currencies
|
||||
.iter()
|
||||
.find(|c| c.code == from.to_uppercase())
|
||||
.ok_or_else(|| {
|
||||
crate::error::ApiError::BadRequest(format!("unknown or disabled currency: {from}"))
|
||||
})?;
|
||||
let to_c = currencies
|
||||
.iter()
|
||||
.find(|c| c.code == to.to_uppercase())
|
||||
.ok_or_else(|| {
|
||||
crate::error::ApiError::BadRequest(format!("unknown or disabled currency: {to}"))
|
||||
})?;
|
||||
let converted = convert_minor(amount_minor, from_c, to_c)?;
|
||||
Ok((converted, to_c.code.clone()))
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
state: &AppState,
|
||||
code: String,
|
||||
name: serde_json::Value,
|
||||
symbol: String,
|
||||
exponent: i16,
|
||||
rate_to_base: rust_decimal::Decimal,
|
||||
enabled: bool,
|
||||
) -> ApiResult<Currency> {
|
||||
use crate::error::ApiError;
|
||||
let code = code.to_uppercase();
|
||||
if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
|
||||
return Err(ApiError::BadRequest("code must be a 3-letter ISO code".into()));
|
||||
}
|
||||
if rate_to_base <= rust_decimal::Decimal::ZERO {
|
||||
return Err(ApiError::BadRequest("rate_to_base must be > 0".into()));
|
||||
}
|
||||
if !(0..=6).contains(&exponent) {
|
||||
return Err(ApiError::BadRequest("exponent must be 0..=6".into()));
|
||||
}
|
||||
Ok(sqlx::query_as::<_, Currency>(
|
||||
"INSERT INTO currencies (code, name, symbol, exponent, rate_to_base, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = $2, symbol = $3, exponent = $4, rate_to_base = $5, enabled = $6
|
||||
RETURNING code, name, symbol, exponent, is_base, rate_to_base, enabled",
|
||||
)
|
||||
.bind(&code)
|
||||
.bind(&name)
|
||||
.bind(&symbol)
|
||||
.bind(exponent)
|
||||
.bind(rate_to_base)
|
||||
.bind(enabled)
|
||||
.fetch_one(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn set_rate(state: &AppState, code: &str, rate: rust_decimal::Decimal) -> ApiResult<Currency> {
|
||||
use crate::error::ApiError;
|
||||
if rate <= rust_decimal::Decimal::ZERO {
|
||||
return Err(ApiError::BadRequest("rate_to_base must be > 0".into()));
|
||||
}
|
||||
sqlx::query_as::<_, Currency>(
|
||||
"UPDATE currencies SET rate_to_base = $2 WHERE code = $1
|
||||
RETURNING code, name, symbol, exponent, is_base, rate_to_base, enabled",
|
||||
)
|
||||
.bind(code.to_uppercase())
|
||||
.bind(rate)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("currency".into()))
|
||||
}
|
||||
Reference in New Issue
Block a user