1use 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
14pub type AppState = Arc<QueryEngine>;
16
17pub async fn health() -> Json<Value> {
19 Json(json!({ "status": "ok" }))
20}
21
22pub 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
30pub 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
44pub 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#[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
94pub 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}