use std::{net::SocketAddr, time::Duration};
use axum::{
Router,
body::Bytes,
extract::{ConnectInfo, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
};
use bc_components::ARID;
use bc_envelope::Envelope;
use bc_ur::prelude::*;
use tokio::net::TcpListener;
use super::{ServerKv, SqliteKv};
use crate::Result;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub port: u16,
pub max_ttl: u64,
pub verbose: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
port: 45678,
max_ttl: 86400, verbose: false,
}
}
}
#[derive(Clone)]
struct ServerState {
storage: ServerKv,
config: ServerConfig,
}
impl ServerState {
fn new(config: ServerConfig, storage: ServerKv) -> Self {
Self { storage, config }
}
fn put(
&self,
arid: ARID,
envelope: Envelope,
requested_ttl: Option<Duration>,
client_ip: Option<SocketAddr>,
) -> std::result::Result<(), String> {
use crate::logging::verbose_println;
let max_duration = Duration::from_secs(self.config.max_ttl);
let ttl = match requested_ttl {
Some(req) => {
if req > max_duration {
max_duration
} else {
req
}
}
None => max_duration,
};
let ttl_seconds = ttl.as_secs();
let result = self.storage.put_sync(arid, envelope, ttl_seconds);
if self.config.verbose {
let ip_str =
client_ip.map(|ip| format!("{}: ", ip)).unwrap_or_default();
let status = match &result {
Ok(_) => "OK".to_string(),
Err(e) => format!("ERROR: {}", e),
};
verbose_println(&format!(
"{}PUT {} (TTL {}s) {}",
ip_str,
arid.ur_string(),
ttl_seconds,
status
));
}
result
}
fn get(
&self,
arid: &ARID,
client_ip: Option<SocketAddr>,
) -> Option<Envelope> {
use crate::logging::verbose_println;
let result = self.storage.get_sync(arid);
if self.config.verbose {
let ip_str =
client_ip.map(|ip| format!("{}: ", ip)).unwrap_or_default();
let status = if result.is_some() { "OK" } else { "NOT_FOUND" };
verbose_println(&format!(
"{}GET {} {}",
ip_str,
arid.ur_string(),
status
));
}
result
}
}
pub struct Server {
config: ServerConfig,
state: ServerState,
}
impl Server {
pub fn new(config: ServerConfig, storage: ServerKv) -> Self {
let state = ServerState::new(config.clone(), storage);
Self { config, state }
}
pub fn new_memory(config: ServerConfig) -> Self {
Self::new(config, ServerKv::memory())
}
pub fn new_sqlite(config: ServerConfig, storage: SqliteKv) -> Self {
Self::new(config, ServerKv::sqlite(storage))
}
pub async fn run(self) -> Result<()> {
let app = Router::new()
.route("/health", get(handle_health))
.route("/put", post(handle_put))
.route("/get", post(handle_get))
.with_state(self.state);
let addr = format!("127.0.0.1:{}", self.config.port);
let listener = TcpListener::bind(&addr).await?;
println!("✓ Hubert server listening on {}", addr);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}
pub fn port(&self) -> u16 { self.config.port }
}
async fn handle_health() -> impl IntoResponse {
let version = env!("CARGO_PKG_VERSION");
let response = serde_json::json!({
"server": "hubert",
"version": version,
"status": "ok"
});
(StatusCode::OK, serde_json::to_string(&response).unwrap())
}
async fn handle_put(
State(state): State<ServerState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
body: Bytes,
) -> std::result::Result<impl IntoResponse, ServerError> {
bc_components::register_tags();
let body_str = String::from_utf8(body.to_vec())
.map_err(|_| ServerError::BadRequest("Invalid UTF-8".to_string()))?;
let lines: Vec<&str> = body_str.lines().collect();
if lines.len() < 2 {
return Err(ServerError::BadRequest(
"Expected at least 2 lines: ur:arid and ur:envelope".to_string(),
));
}
let arid = ARID::from_ur_string(lines[0])
.map_err(|_| ServerError::BadRequest("Invalid ur:arid".to_string()))?;
let envelope = Envelope::from_ur_string(lines[1]).map_err(|_| {
ServerError::BadRequest("Invalid ur:envelope".to_string())
})?;
let ttl = if lines.len() > 2 {
let seconds: u64 = lines[2]
.parse()
.map_err(|_| ServerError::BadRequest("Invalid TTL".to_string()))?;
Some(Duration::from_secs(seconds))
} else {
None
};
state
.put(arid, envelope, ttl, Some(addr))
.map_err(ServerError::Conflict)?;
Ok((StatusCode::OK, "OK"))
}
async fn handle_get(
State(state): State<ServerState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
body: Bytes,
) -> std::result::Result<impl IntoResponse, ServerError> {
bc_components::register_tags();
let body_str = String::from_utf8(body.to_vec())
.map_err(|_| ServerError::BadRequest("Invalid UTF-8".to_string()))?;
let arid_str = body_str.trim();
if arid_str.is_empty() {
return Err(ServerError::BadRequest("Expected ur:arid".to_string()));
}
let arid = ARID::from_ur_string(arid_str)
.map_err(|_| ServerError::BadRequest("Invalid ur:arid".to_string()))?;
match state.get(&arid, Some(addr)) {
Some(envelope) => Ok((StatusCode::OK, envelope.ur_string())),
None => Err(ServerError::NotFound),
}
}
#[derive(Debug)]
enum ServerError {
BadRequest(String),
Conflict(String),
NotFound,
}
impl IntoResponse for ServerError {
fn into_response(self) -> Response {
match self {
ServerError::BadRequest(msg) => {
(StatusCode::BAD_REQUEST, msg).into_response()
}
ServerError::Conflict(msg) => {
(StatusCode::CONFLICT, msg).into_response()
}
ServerError::NotFound => {
(StatusCode::NOT_FOUND, "Not found").into_response()
}
}
}
}