[feat] introduced server and cli crates with foundational HTTP server and CLI implementation, including routing, health check, and configuration setup

This commit is contained in:
2025-08-20 09:58:21 +02:00
parent 16167d18ff
commit d37daf02f6
12 changed files with 457 additions and 1275 deletions

1385
backend-rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,36 @@
[workspace]
members = [
"crates/app",
"crates/api",
"crates/module-api",
"crates/module-host",
"crates/modules/summarizer",
"crates/server",
"crates/cli",
]
resolver = "2"
resolver = "3"
[workspace.package]
edition = "2024"
version = "0.1.0"
rust-version = "1.89"
[workspace.dependencies]
anyhow = "1.0"
tokio = "1"
axum = "0.8.4"
serde = "1.0"
serde_json = "1.0"
sqlx = "0.8"
dotenv = "0.15"
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1.0.99"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "signal"] }
libloading = "0.8.8"
tracing = "0.1.41"
once_cell = "1.21.3"
toml = "0.9.5"
unicode-segmentation = "1.12.0"
axum = "0.8.4"
sha2 = "0.10.9"
sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "macros", "postgres", "uuid", "chrono", "migrate"] }
hex = "0.4.3"
num_cpus = "1.17.0"
unicode-segmentation = "1.12.0"
readability = "0.3.0"
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] }
scraper = "0.23.1"
libloading = "0.8"
async-trait = "0.1"
once_cell = "1.19"
num_cpus = "1.16"
dotenv = "0.15.0"
# Dev-only deps centralized (optional)
tokio-test = "0.4"
axum-test = "17.3"
# dev/test utilities in the workspace
tokio-test = "0.4.4"
axum-test = "17.3.0"

View File

@@ -1,28 +1,16 @@
[package]
name = "owly-news-api"
version.workspace = true
edition.workspace = true
[lib]
path = "src/lib.rs"
name = "api"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
tokio = { workspace = true, features = ["full"] }
axum = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true, features = ["runtime-tokio", "tls-native-tls", "sqlite", "macros", "migrate", "chrono", "json"] }
dotenv = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter", "json"] }
once_cell = { workspace = true }
toml = { workspace = true }
unicode-segmentation = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
readability = { workspace = true }
scraper = { workspace = true }
tracing = { workspace = true }
async-trait = "0.1.89"
[dev-dependencies]
tokio-test = { workspace = true }
axum-test = { workspace = true }
[features]
default = []

View File

@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(default)]
pub struct Cli {
#[serde(default)]
pub default_output: DefaultOutput,
#[serde(default = "Cli::default_pager_command")]
pub pager_command: String,
#[serde(default = "Cli::default_show_progress")]
pub show_progress: bool,
#[serde(default = "Cli::default_auto_confirm_bulk")]
pub auto_confirm_bulk: bool,
#[serde(default = "Cli::default_show_geographic_hierarchy")]
pub show_geographic_hierarchy: bool,
}
impl Default for Cli {
fn default() -> Self {
Self {
default_output: DefaultOutput::Table,
pager_command: Self::default_pager_command(),
show_progress: Self::default_show_progress(),
auto_confirm_bulk: Self::default_auto_confirm_bulk(),
show_geographic_hierarchy: Self::default_show_geographic_hierarchy(),
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
pub enum DefaultOutput {
Table,
Json,
}
impl Default for DefaultOutput {
fn default() -> Self {
DefaultOutput::Table
}
}
impl Cli {
pub fn default_pager_command() -> String {
// Example default; customize as needed
"less -R".to_string()
}
pub fn default_show_progress() -> bool {
true
}
pub fn default_auto_confirm_bulk() -> bool {
false
}
pub fn default_show_geographic_hierarchy() -> bool {
true
}
}

View File

@@ -1,2 +1,5 @@
pub mod api;
pub use api::*;
//! API-first core: shared types, DTOs, service traits, configuration.
pub mod config;
pub mod types;
pub mod services;

View File

@@ -0,0 +1,18 @@
use crate::types::Health;
use async_trait::async_trait;
// Implement your service traits here. Example:
#[async_trait]
pub trait HealthService: Send + Sync {
async fn health(&self) -> Health;
}
// A trivial default implementation that can be used by server and tests.
pub struct DefaultHealthService;
#[async_trait]
impl HealthService for DefaultHealthService {
async fn health(&self) -> Health {
Health { status: "ok".into() }
}
}

View File

@@ -0,0 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Health {
pub status: String,
}

View File

@@ -0,0 +1,15 @@
[package]
name = "cli"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
dotenv = { workspace = true }
api = { path = "../api" }
server = { path = "../server" }

View File

@@ -0,0 +1,70 @@
use anyhow::Result;
use api::config::Cli;
use dotenv::dotenv;
use std::{env, net::SocketAddr, str::FromStr};
use tokio::signal;
#[tokio::main]
async fn main() -> Result<()> {
dotenv().ok();
let args: Vec<String> = env::args().collect();
match args.get(1).map(|s| s.as_str()) {
Some("serve") => serve(args).await,
Some("print-config") => print_config(),
_ => {
print_help();
Ok(())
}
}
}
fn print_help() {
eprintln!(
"Usage:
cli serve [--addr 0.0.0.0:8080]
cli print-config
Environment:
These may influence runtime behavior.
Notes:
- 'serve' runs the HTTP server.
- 'print-config' prints the default CLI configuration in JSON."
);
}
async fn serve(args: Vec<String>) -> Result<()> {
// naive flag parse: look for "--addr host:port"
let mut addr: SocketAddr = SocketAddr::from_str("127.0.0.1:8080")?;
let mut i = 2;
while i + 1 < args.len() {
if args[i] == "--addr" {
addr = SocketAddr::from_str(&args[i + 1])?;
i += 2;
} else {
i += 1;
}
}
let server_task = tokio::spawn(async move { server::start_server(addr).await });
// graceful shutdown via Ctrl+C
tokio::select! {
res = server_task => {
res??;
}
_ = signal::ctrl_c() => {
eprintln!("Shutting down...");
}
}
Ok(())
}
fn print_config() -> Result<()> {
let cfg = Cli::default();
let json = serde_json::to_string_pretty(&cfg)?;
println!("{json}");
Ok(())
}

View File

@@ -0,0 +1,22 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
axum = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
dotenv = { workspace = true }
once_cell = { workspace = true }
api = { path = "../api" }
http = "1.3.1"
[features]
default = []

View File

@@ -0,0 +1,58 @@
use axum::{routing::get, Json, Router};
use std::{net::SocketAddr, sync::Arc};
use tokio::net::TcpListener;
use tracing::{info, level_filters::LevelFilter};
use tracing_subscriber::EnvFilter;
use api::services::{DefaultHealthService, HealthService};
use api::types::Health;
pub struct AppState {
pub health_service: Arc<dyn HealthService>,
}
pub async fn build_router(state: Arc<AppState>) -> Router {
Router::new().route(
"/health",
get({
let state = state.clone();
move || health_handler(state.clone())
}),
)
}
async fn health_handler(state: Arc<AppState>) -> Json<Health> {
let res = state.health_service.health().await;
Json(res)
}
pub async fn start_server(addr: SocketAddr) -> anyhow::Result<()> {
init_tracing();
// TODO: initialize database pools and other infrastructure here.
// let pool = sqlx::PgPool::connect(&db_url).await?;
let state = Arc::new(AppState {
health_service: Arc::new(DefaultHealthService),
});
let app = build_router(state).await;
let listener = TcpListener::bind(addr).await?;
info!("HTTP server listening on http://{}", addr);
axum::serve(listener, app).await?;
Ok(())
}
fn init_tracing() {
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))
.unwrap()
.add_directive(LevelFilter::INFO.into());
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_target(true)
.compact()
.init();
}

View File

@@ -0,0 +1,22 @@
use axum::Router;
use server::{build_router, AppState};
use api::services::DefaultHealthService;
use std::sync::Arc;
#[tokio::test]
async fn health_ok() {
let state = Arc::new(AppState {
health_service: Arc::new(DefaultHealthService),
});
let app: Router = build_router(state).await;
let req = http::Request::builder()
.uri("/health")
.body(axum::body::Body::empty())
.unwrap();
let res = axum::http::Request::from(req);
let res = axum::http::Request::from(res);
let _ = app; // You can use axum-test to send requests if desired.
}