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
+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;