chore(server): scaffold axum com health endpoint e testes

This commit is contained in:
2026-08-05 20:23:09 -03:00
parent bfe12ba7ae
commit bc4de69ab9
11 changed files with 2013 additions and 0 deletions
+1796
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "ares-server"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full", "macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
rusqlite = { version = "0.32", features = ["bundled"] }
thiserror = "2"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
[dev-dependencies]
axum-test = "21"
tempfile = "3"
+10
View File
@@ -0,0 +1,10 @@
use axum::Json;
use serde_json::{json, Value};
pub async fn health() -> Json<Value> {
Json(json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
"service": "ares-next",
}))
}
+15
View File
@@ -0,0 +1,15 @@
pub mod health;
use axum::{routing::get, Router};
use tower_http::cors::CorsLayer;
use crate::state::SharedState;
pub fn router(state: SharedState) -> Router {
let api = Router::new()
.route("/health", get(health::health))
.with_state(state);
Router::new()
.nest("/api/v1", api)
.layer(CorsLayer::permissive())
}
+48
View File
@@ -0,0 +1,48 @@
use std::env;
#[derive(Clone, Debug)]
pub struct Config {
pub host: String,
pub port: u16,
pub db_path: String,
pub max_body_mb: usize,
pub max_results: i64,
}
impl Default for Config {
fn default() -> Self {
Self {
host: "127.0.0.1".into(),
port: 3000,
db_path: "data/aresnext.db".into(),
max_body_mb: 2,
max_results: 50,
}
}
}
impl Config {
pub fn from_env() -> Self {
let def = Self::default();
Self {
host: env::var("ARS_NEXT_HOST").unwrap_or(def.host),
port: env::var("ARS_NEXT_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(def.port),
db_path: env::var("ARS_NEXT_DB").unwrap_or(def.db_path),
max_body_mb: env::var("ARS_NEXT_MAX_BODY_MB")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(def.max_body_mb),
max_results: env::var("ARS_NEXT_MAX_RESULTS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(def.max_results),
}
}
pub fn max_body_bytes(&self) -> usize {
self.max_body_mb * 1024 * 1024
}
}
+33
View File
@@ -0,0 +1,33 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("bad request: {0}")]
BadRequest(String),
#[error("not found: {0}")]
NotFound(String),
#[error("database error: {0}")]
Db(#[from] rusqlite::Error),
#[error("internal: {0}")]
Internal(String),
}
impl AppError {
fn status(&self) -> StatusCode {
match self {
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::Db(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let body = json!({ "error": self.to_string() });
(self.status(), Json(body)).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;
+18
View File
@@ -0,0 +1,18 @@
pub mod api;
pub mod config;
pub mod error;
pub mod state;
pub mod store;
use axum::Router;
use crate::config::Config;
use crate::state::{AppState, SharedState};
pub async fn build_app(cfg: Config) -> SharedState {
let conn = store::open(&cfg).expect("open database");
AppState::new(cfg, conn)
}
pub fn router(state: SharedState) -> Router {
api::router(state)
}
+20
View File
@@ -0,0 +1,20 @@
use ares_server::config::Config;
use ares_server::{build_app, router};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cfg = Config::from_env();
let addr = format!("{}:{}", cfg.host, cfg.port);
let state = build_app(cfg).await;
let app = router(state);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("bind listener");
tracing::info!("AresNext listening on http://{addr}");
axum::serve(listener, app).await.expect("server");
}
+21
View File
@@ -0,0 +1,21 @@
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use crate::config::Config;
pub type DbConn = Mutex<Connection>;
pub struct AppState {
pub config: Config,
pub db: DbConn,
}
impl AppState {
pub fn new(config: Config, db: Connection) -> Arc<Self> {
Arc::new(Self {
config,
db: Mutex::new(db),
})
}
}
pub type SharedState = Arc<AppState>;
+13
View File
@@ -0,0 +1,13 @@
use rusqlite::Connection;
use crate::config::Config;
use crate::error::AppResult;
pub fn open(cfg: &Config) -> AppResult<Connection> {
if let Some(parent) = std::path::Path::new(&cfg.db_path).parent() {
std::fs::create_dir_all(parent).ok();
}
let db = Connection::open(&cfg.db_path)?;
db.pragma_update(None, "journal_mode", "WAL")?;
db.pragma_update(None, "synchronous", "NORMAL")?;
Ok(db)
}
+18
View File
@@ -0,0 +1,18 @@
use axum_test::TestServer;
use ares_server::config::Config;
use ares_server::{build_app, router};
use tempfile::tempdir;
#[tokio::test]
async fn health_returns_ok() {
let dir = tempdir().unwrap();
let cfg = Config {
db_path: dir.path().join("t.db").to_string_lossy().into(),
..Default::default()
};
let state = build_app(cfg).await;
let server = TestServer::new(router(state));
let res = server.get("/api/v1/health").await;
assert_eq!(res.status_code(), 200);
assert_eq!(res.json::<serde_json::Value>()["status"], "ok");
}