feat: add axum HTTP layer with lookup endpoint and healthz

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 15:19:18 +02:00
parent 0880198b3c
commit 58f4bd4fdf
4 changed files with 275 additions and 1 deletions
Generated
+99
View File
@@ -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",
]
+4 -1
View File
@@ -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"] }
+171
View File
@@ -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<LookupService>) -> 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<Arc<LookupService>>,
Path(raw): Path<String>,
) -> 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 215 digits.
pub fn normalize(raw: &str) -> Option<String> {
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<Vec<String>, 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<FetchedResponse, FetchError> {
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);
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod error;
pub mod fetch;
pub mod http;
pub mod model;
pub mod service;