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:
@@ -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