From 58f4bd4fdf4336066af75a573d582ddbbafe16cd Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 5 Jun 2026 15:19:18 +0200 Subject: [PATCH] feat: add axum HTTP layer with lookup endpoint and healthz Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 99 ++++++++++++++++++++++ crates/server/Cargo.toml | 5 +- crates/server/src/http.rs | 171 ++++++++++++++++++++++++++++++++++++++ crates/server/src/lib.rs | 1 + 4 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 crates/server/src/http.rs diff --git a/Cargo.lock b/Cargo.lock index b92b4ab..a3d060d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,58 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -867,6 +919,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -881,6 +939,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1254,6 +1313,12 @@ dependencies = [ "syn", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.1" @@ -1821,6 +1886,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1921,6 +1992,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1930,6 +2012,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2270,6 +2364,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2308,6 +2403,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2966,13 +3062,16 @@ name = "whoareyou-server" version = "0.1.0" dependencies = [ "async-trait", + "axum", "futures", + "http-body-util", "moka", "reqwest", "serde", "serde_json", "thiserror 2.0.18", "tokio", + "tower", "tracing", "wasmtime", ] diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index f5bda30..097a314 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -6,14 +6,17 @@ authors.workspace = true [dependencies] async-trait = "0.1" +axum = "0.8" futures = "0.3" moka = { version = "0.12", features = ["future"] } reqwest = "0.13" serde = { version = "1", features = ["derive"] } +serde_json = "1" thiserror = "2" tokio = { version = "1", features = ["full"] } tracing = "0.1" wasmtime = { version = "45", features = ["component-model"] } [dev-dependencies] -serde_json = "1" +http-body-util = "0.1" +tower = { version = "0.5", features = ["util"] } diff --git a/crates/server/src/http.rs b/crates/server/src/http.rs new file mode 100644 index 0000000..8514bcb --- /dev/null +++ b/crates/server/src/http.rs @@ -0,0 +1,171 @@ +use std::sync::Arc; + +use axum::Json; +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use serde_json::json; + +use crate::model::LookupResponse; +use crate::service::LookupService; + +pub fn router(service: Arc) -> Router { + Router::new() + .route("/api/v1/number/{number}", get(lookup_number)) + .route("/healthz", get(|| async { "ok" })) + .with_state(service) +} + +async fn lookup_number( + State(service): State>, + Path(raw): Path, +) -> Response { + let Some(number) = normalize(&raw) else { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "invalid phone number" })), + ) + .into_response(); + }; + + let results = service.lookup(&number).await; + + Json(LookupResponse { number, results }).into_response() +} + +/// Strip separators and validate: optional leading '+', then 2–15 digits. +pub fn normalize(raw: &str) -> Option { + let cleaned: String = raw + .chars() + .filter(|c| !matches!(c, ' ' | '-' | '.')) + .collect(); + + let digits = cleaned.strip_prefix('+').unwrap_or(&cleaned); + + let valid = (2..=15).contains(&digits.len()) && digits.chars().all(|c| c.is_ascii_digit()); + + valid.then_some(cleaned) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use async_trait::async_trait; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use tower::ServiceExt; + + use super::*; + use crate::error::{FetchError, HostError}; + use crate::model::{FetchedResponse, ParseOutcome}; + use crate::service::{Fetch, LookupService, ProviderHandle}; + + struct NoDataProvider; + + impl ProviderHandle for NoDataProvider { + fn name(&self) -> &str { + "fake.se" + } + + fn requests(&self, number: &str) -> Result, HostError> { + Ok(vec![format!("https://example.test/{number}")]) + } + + fn parse(&self, _: &str, _: &[FetchedResponse]) -> ParseOutcome { + ParseOutcome::NoData + } + } + + struct StaticFetcher; + + #[async_trait] + impl Fetch for StaticFetcher { + async fn fetch(&self, _: &str) -> Result { + Ok(FetchedResponse { + status: 200, + body: String::new(), + }) + } + } + + fn app() -> axum::Router { + let service = LookupService::new( + vec![Arc::new(NoDataProvider)], + Arc::new(StaticFetcher), + Duration::from_secs(60), + ); + + router(Arc::new(service)) + } + + #[test] + fn normalize_strips_separators() { + assert_eq!(normalize("0700 00-00.00"), Some("0700000000".to_string())); + assert_eq!(normalize("+46701234567"), Some("+46701234567".to_string())); + } + + #[test] + fn normalize_rejects_garbage() { + assert_eq!(normalize("not-a-number"), None); + assert_eq!(normalize(""), None); + assert_eq!(normalize("0"), None); + assert_eq!(normalize("07001231231231231231"), None); // > 15 digits + assert_eq!(normalize("070+123"), None); // '+' not at start + } + + #[tokio::test] + async fn lookup_returns_results_keyed_by_provider() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/v1/number/0700%2000-00%2000") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(json["number"], "0700000000"); + assert_eq!(json["results"]["fake.se"]["status"], "no_data"); + } + + #[tokio::test] + async fn invalid_number_is_400() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/v1/number/banana") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn healthz_is_ok() { + let response = app() + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 81f6d19..39397d0 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1,4 +1,5 @@ pub mod error; pub mod fetch; +pub mod http; pub mod model; pub mod service;