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:
Generated
+99
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 2–15 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,4 +1,5 @@
|
||||
pub mod error;
|
||||
pub mod fetch;
|
||||
pub mod http;
|
||||
pub mod model;
|
||||
pub mod service;
|
||||
|
||||
Reference in New Issue
Block a user