WP-00: project skeleton with three-level test harness
Cargo workspace (domain, application, infrastructure, api), axum health endpoint with SPA fallback, Vue 3 + Tailwind frontend, Vitest, Playwright, Makefile, Gitea Actions CI and README. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
34
backend/crates/api/Cargo.toml
Normal file
34
backend/crates/api/Cargo.toml
Normal file
@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "monitoring-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
application.workspace = true
|
||||
infrastructure.workspace = true
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
dotenvy.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
utoipa.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util.workspace = true
|
||||
tower.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
35
backend/crates/api/src/config.rs
Normal file
35
backend/crates/api/src/config.rs
Normal file
@ -0,0 +1,35 @@
|
||||
//! Runtime configuration read from environment variables (see `.env.example`).
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
pub bind: SocketAddr,
|
||||
pub bootstrap_admin: Option<(String, String)>,
|
||||
pub cookie_secure: bool,
|
||||
pub frontend_dir: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let jwt_secret =
|
||||
env("JWT_SECRET").ok_or_else(|| anyhow::anyhow!("JWT_SECRET is required"))?;
|
||||
anyhow::ensure!(
|
||||
jwt_secret.len() >= 32,
|
||||
"JWT_SECRET must be at least 32 characters"
|
||||
);
|
||||
Ok(Self {
|
||||
database_url: env("DATABASE_URL")
|
||||
.unwrap_or_else(|| "sqlite://data/monitoring.db?mode=rwc".into()),
|
||||
jwt_secret,
|
||||
bind: env("BIND")
|
||||
.unwrap_or_else(|| "127.0.0.1:8080".into())
|
||||
.parse()?,
|
||||
bootstrap_admin: env("BOOTSTRAP_ADMIN_EMAIL").zip(env("BOOTSTRAP_ADMIN_PASSWORD")),
|
||||
cookie_secure: env("COOKIE_SECURE").is_some_and(|v| v == "true" || v == "1"),
|
||||
frontend_dir: env("FRONTEND_DIR").unwrap_or_else(|| "../frontend/dist".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
28
backend/crates/api/src/lib.rs
Normal file
28
backend/crates/api/src/lib.rs
Normal file
@ -0,0 +1,28 @@
|
||||
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
|
||||
pub mod config;
|
||||
pub mod test_support;
|
||||
|
||||
use axum::{routing::get, Json, Router};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub cfg: Config,
|
||||
}
|
||||
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
let index = format!("{}/index.html", state.cfg.frontend_dir);
|
||||
let spa = ServeDir::new(&state.cfg.frontend_dir).not_found_service(ServeFile::new(index));
|
||||
Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.fallback_service(spa)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn healthz() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "status": "ok" }))
|
||||
}
|
||||
15
backend/crates/api/src/main.rs
Normal file
15
backend/crates/api/src/main.rs
Normal file
@ -0,0 +1,15 @@
|
||||
use api::{build_app, AppState, Config};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let _ = dotenvy::dotenv();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
let cfg = Config::from_env()?;
|
||||
let listener = tokio::net::TcpListener::bind(cfg.bind).await?;
|
||||
tracing::info!("listening on http://{}", cfg.bind);
|
||||
axum::serve(listener, build_app(AppState { cfg })).await?;
|
||||
Ok(())
|
||||
}
|
||||
18
backend/crates/api/src/test_support.rs
Normal file
18
backend/crates/api/src/test_support.rs
Normal file
@ -0,0 +1,18 @@
|
||||
//! Helpers to build a fully wired app on an in-memory database for integration tests.
|
||||
use crate::{build_app, AppState, Config};
|
||||
use axum::Router;
|
||||
|
||||
pub fn test_config() -> Config {
|
||||
Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
jwt_secret: "test-secret-test-secret-test-secret-1234".into(),
|
||||
bind: "127.0.0.1:0".parse().unwrap(),
|
||||
bootstrap_admin: None,
|
||||
cookie_secure: false,
|
||||
frontend_dir: "/nonexistent".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_test_app() -> Router {
|
||||
build_app(AppState { cfg: test_config() })
|
||||
}
|
||||
89
backend/crates/api/tests/common/mod.rs
Normal file
89
backend/crates/api/tests/common/mod.rs
Normal file
@ -0,0 +1,89 @@
|
||||
//! Shared helpers for API integration tests: in-memory SQLite, request helpers.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use axum::Router;
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
pub struct Res {
|
||||
pub status: StatusCode,
|
||||
pub json: serde_json::Value,
|
||||
pub headers: axum::http::HeaderMap,
|
||||
}
|
||||
|
||||
pub async fn test_app() -> Router {
|
||||
api::test_support::build_test_app().await
|
||||
}
|
||||
|
||||
async fn send(app: &Router, req: Request<Body>) -> Res {
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let headers = res.headers().clone();
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
|
||||
Res {
|
||||
status,
|
||||
json,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_auth(
|
||||
mut b: axum::http::request::Builder,
|
||||
token: Option<&str>,
|
||||
) -> axum::http::request::Builder {
|
||||
if let Some(t) = token {
|
||||
b = b.header(header::AUTHORIZATION, format!("Bearer {t}"));
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub async fn get(app: &Router, path: &str, token: Option<&str>) -> Res {
|
||||
let req = with_auth(Request::get(path), token)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
send(app, req).await
|
||||
}
|
||||
|
||||
pub async fn send_json(
|
||||
app: &Router,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: serde_json::Value,
|
||||
token: Option<&str>,
|
||||
) -> Res {
|
||||
let req = with_auth(Request::builder().method(method).uri(path), token)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap();
|
||||
send(app, req).await
|
||||
}
|
||||
|
||||
pub async fn post(app: &Router, path: &str, body: serde_json::Value, token: Option<&str>) -> Res {
|
||||
send_json(app, "POST", path, body, token).await
|
||||
}
|
||||
|
||||
pub async fn patch(app: &Router, path: &str, body: serde_json::Value, token: Option<&str>) -> Res {
|
||||
send_json(app, "PATCH", path, body, token).await
|
||||
}
|
||||
|
||||
/// POST with a `Cookie` header (used for refresh/logout which read the refresh cookie).
|
||||
pub async fn post_with_cookie(app: &Router, path: &str, cookie: &str) -> Res {
|
||||
let req = Request::post(path)
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
send(app, req).await
|
||||
}
|
||||
|
||||
/// Extract `name=value` of a Set-Cookie header.
|
||||
pub fn cookie_value(res: &Res, name: &str) -> Option<String> {
|
||||
res.headers
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.find(|c| c.starts_with(&format!("{name}=")))
|
||||
.map(|c| c.split(';').next().unwrap().to_string())
|
||||
}
|
||||
12
backend/crates/api/tests/healthz.rs
Normal file
12
backend/crates/api/tests/healthz.rs
Normal file
@ -0,0 +1,12 @@
|
||||
//! WP-00 sample integration test: the health endpoint answers 200 with `{"status":"ok"}`.
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_ok() {
|
||||
let app = common::test_app().await;
|
||||
let res = common::get(&app, "/healthz", None).await;
|
||||
assert_eq!(res.status, StatusCode::OK);
|
||||
assert_eq!(res.json["status"], "ok");
|
||||
}
|
||||
Reference in New Issue
Block a user