Skip to main content

uls_api/
handlers.rs

1//! Request handlers for the API endpoints.
2
3use std::sync::Arc;
4
5use axum::extract::{Path, Query, State};
6use axum::Json;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use uls_query::{QueryEngine, SearchFilter};
10
11use crate::error::ApiError;
12use crate::response::ListResponse;
13
14/// Shared application state.
15pub type AppState = Arc<QueryEngine>;
16
17/// GET /health
18pub async fn health() -> Json<Value> {
19    Json(json!({ "status": "ok" }))
20}
21
22/// GET /stats
23pub async fn stats(State(engine): State<AppState>) -> Result<Json<Value>, ApiError> {
24    let stats = engine.stats()?;
25    Ok(Json(
26        serde_json::to_value(stats).map_err(|e| ApiError::Internal(e.to_string()))?,
27    ))
28}
29
30/// GET /licenses/:callsign
31pub async fn lookup(
32    State(engine): State<AppState>,
33    Path(callsign): Path<String>,
34) -> Result<Json<Value>, ApiError> {
35    let license = engine
36        .lookup(&callsign)?
37        .ok_or_else(|| ApiError::NotFound(format!("No license found for callsign {}", callsign)))?;
38
39    Ok(Json(
40        serde_json::to_value(license).map_err(|e| ApiError::Internal(e.to_string()))?,
41    ))
42}
43
44/// GET /frn/:frn
45pub async fn frn_lookup(
46    State(engine): State<AppState>,
47    Path(frn): Path<String>,
48) -> Result<Json<Value>, ApiError> {
49    if frn.len() != 10 || !frn.chars().all(|c| c.is_ascii_digit()) {
50        return Err(ApiError::BadRequest(
51            "FRN must be exactly 10 digits".to_string(),
52        ));
53    }
54
55    let licenses = engine.lookup_by_frn(&frn)?;
56    if licenses.is_empty() {
57        return Err(ApiError::NotFound(format!(
58            "No licenses found for FRN {}",
59            frn
60        )));
61    }
62
63    let response = ListResponse::new(licenses, 0, 0);
64    Ok(Json(
65        serde_json::to_value(response).map_err(|e| ApiError::Internal(e.to_string()))?,
66    ))
67}
68
69/// Query parameters for the search endpoint.
70#[derive(Debug, Deserialize)]
71pub struct SearchParams {
72    pub name: Option<String>,
73    pub callsign: Option<String>,
74    pub state: Option<String>,
75    pub city: Option<String>,
76    pub zip: Option<String>,
77    pub frn: Option<String>,
78    pub status: Option<String>,
79    pub class: Option<String>,
80    pub service: Option<String>,
81    pub sort: Option<String>,
82    pub limit: Option<usize>,
83    pub offset: Option<usize>,
84    pub active: Option<bool>,
85    pub granted_after: Option<String>,
86    pub granted_before: Option<String>,
87    pub expires_before: Option<String>,
88    pub filter: Option<String>,
89}
90
91const DEFAULT_LIMIT: usize = 50;
92const MAX_LIMIT: usize = 1000;
93
94/// GET /licenses
95pub async fn search(
96    State(engine): State<AppState>,
97    Query(params): Query<SearchParams>,
98) -> Result<Json<Value>, ApiError> {
99    let mut filter = SearchFilter::new();
100
101    if let Some(ref name) = params.name {
102        let pattern = if name.contains('*') || name.contains('?') {
103            name.clone()
104        } else {
105            format!("*{}*", name)
106        };
107        filter.name = Some(pattern);
108    }
109
110    if let Some(ref callsign) = params.callsign {
111        filter.callsign = Some(callsign.clone());
112    }
113
114    if let Some(ref state) = params.state {
115        filter.state = Some(state.clone());
116    }
117
118    if let Some(ref city) = params.city {
119        filter = filter.with_filter(format!("city={}", city));
120    }
121
122    if let Some(ref zip) = params.zip {
123        filter.zip_code = Some(zip.clone());
124    }
125
126    if let Some(ref frn) = params.frn {
127        filter.frn = Some(frn.clone());
128    }
129
130    if let Some(ref status) = params.status {
131        if let Some(c) = status.chars().next() {
132            filter.status = Some(c.to_ascii_uppercase());
133        }
134    }
135
136    if let Some(ref class) = params.class {
137        if let Some(c) = class.chars().next() {
138            filter = filter.with_operator_class(c.to_ascii_uppercase());
139        }
140    }
141
142    if let Some(ref service) = params.service {
143        let codes = match service.to_lowercase().as_str() {
144            "amateur" | "ham" | "ha" => vec!["HA".to_string(), "HV".to_string()],
145            "gmrs" | "za" => vec!["ZA".to_string()],
146            _ => {
147                return Err(ApiError::BadRequest(format!(
148                    "Unknown service: {}",
149                    service
150                )))
151            }
152        };
153        filter.radio_service = Some(codes);
154    }
155
156    if params.active == Some(true) {
157        filter = filter.active_only();
158    }
159
160    filter.granted_after = params.granted_after;
161    filter.granted_before = params.granted_before;
162    filter.expires_before = params.expires_before;
163
164    if let Some(ref filter_str) = params.filter {
165        for expr in filter_str.split(',') {
166            let trimmed = expr.trim();
167            if !trimmed.is_empty() {
168                filter = filter.with_filter(trimmed);
169            }
170        }
171    }
172
173    if let Some(ref sort) = params.sort {
174        filter = filter.with_sort_field(sort);
175    }
176
177    let limit = params.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
178    let offset = params.offset.unwrap_or(0);
179    filter = filter.with_limit(limit);
180    if offset > 0 {
181        filter = filter.with_offset(offset);
182    }
183
184    let licenses = engine.search(filter)?;
185    let response = ListResponse::new(licenses, limit, offset);
186
187    Ok(Json(
188        serde_json::to_value(response).map_err(|e| ApiError::Internal(e.to_string()))?,
189    ))
190}