1use std::{
2 net::SocketAddr,
3 path::{Path, PathBuf},
4 sync::{
5 Arc,
6 atomic::{AtomicBool, Ordering},
7 },
8};
9
10use anyhow::{Context, Result};
11use axum::{
12 Extension, Json, Router,
13 extract::{Query, State},
14 handler::{Handler, HandlerWithoutStateExt},
15 middleware,
16 routing::{MethodFilter, MethodRouter, on},
17};
18use serde::{Deserialize, Serialize};
19use tokio::{net::TcpListener, task};
20use tower_http::services::{ServeDir, ServeFile};
21
22use crate::{
23 http::{
24 bounds, health, method,
25 problem::{Problem, classify_search_error},
26 request_id::{self, RequestId},
27 },
28 pack::{PackReader, RecordPoint, RecordPointPrecision, RecordSource},
29 record::OsmObjectType,
30 reverse::{PackReverseGeocoder, ReverseGeocodeOptions, ReverseGeocodeResponse},
31 search::{PackTextSearcher, TextAutocompleteOptions, TextSearchHit, TextSearchOptions},
32};
33
34#[derive(Debug, Clone)]
35pub struct ServeOptions {
36 pub pack: PathBuf,
37 pub demo: PathBuf,
38 pub bind: SocketAddr,
39 pub basemap: PathBuf,
40}
41
42#[derive(Clone)]
43pub(crate) struct AppState {
44 searcher: Arc<PackTextSearcher>,
45 reverse_geocoder: Arc<PackReverseGeocoder>,
46 ready: Arc<AtomicBool>,
47}
48
49impl AppState {
50 fn open(path: &Path) -> Result<Self> {
51 let pack = Arc::new(PackReader::open(path)?);
52 let searcher = PackTextSearcher::from_pack(Arc::clone(&pack))?;
53 let reverse_geocoder = PackReverseGeocoder::from_pack(pack)?;
54 Ok(Self {
55 searcher: Arc::new(searcher),
56 reverse_geocoder: Arc::new(reverse_geocoder),
57 ready: Arc::new(AtomicBool::new(true)),
58 })
59 }
60
61 pub(crate) fn is_ready(&self) -> bool {
64 self.ready.load(Ordering::Acquire)
65 }
66}
67
68#[derive(Debug, Deserialize)]
69struct SearchParams {
70 q: Option<String>,
71 #[serde(default)]
72 limit: usize,
73 layer: Option<String>,
74}
75
76#[derive(Debug, Deserialize)]
77struct ReverseParams {
78 lon: f64,
79 lat: f64,
80}
81
82#[derive(Debug, Serialize, PartialEq)]
83pub struct SearchResponse {
84 pub query: String,
85 pub results: Vec<SearchApiResult>,
86}
87
88#[derive(Debug, Serialize, PartialEq)]
89pub struct AutocompleteResponse {
90 pub query: String,
91 pub suggestions: Vec<SearchApiResult>,
92}
93
94#[derive(Debug, Serialize, PartialEq)]
95pub struct SearchApiResult {
96 pub record_id: u64,
97 pub id: String,
98 pub layer: String,
99 pub label: String,
100 pub score: f32,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub point: Option<SearchApiPoint>,
103 pub source: SearchApiSource,
104}
105
106#[derive(Debug, Clone, Serialize, PartialEq)]
107pub struct SearchApiPoint {
108 pub lon: f64,
109 pub lat: f64,
110 pub precision: SearchApiPointPrecision,
111}
112
113#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
114#[serde(rename_all = "snake_case")]
115pub enum SearchApiPointPrecision {
116 Point,
117 Centroid,
118 RepresentativePoint,
119}
120
121#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
122pub struct SearchApiSource {
123 pub dataset: String,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub object_type: Option<OsmObjectType>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub object_id: Option<i64>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub derived_from: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub record_count: Option<u64>,
132}
133
134pub async fn serve(options: ServeOptions) -> Result<()> {
135 let state = AppState::open(&options.pack)
136 .with_context(|| format!("failed to open Pack {}", options.pack.display()))?;
137 let app = build_router(state, &options.demo, &options.basemap);
138
139 let listener = TcpListener::bind(options.bind)
140 .await
141 .with_context(|| format!("failed to bind {}", options.bind))?;
142 println!(
143 "Serving {} at http://{}",
144 options.pack.display(),
145 options.bind
146 );
147 axum::serve(listener, app)
148 .await
149 .context("runtime server failed")
150}
151
152pub(crate) fn build_router(state: AppState, demo: &Path, basemap: &Path) -> Router {
160 let mut app: Router<AppState> = Router::new()
165 .route("/search", get_only(search))
166 .route("/autocomplete", get_only(autocomplete))
167 .route("/reverse", get_only(reverse))
168 .route("/healthz", get_only(health::healthz))
169 .route("/readyz", get_only(health::readyz));
170
171 if basemap.exists() {
182 app = app.route_service("/basemap.pmtiles", ServeFile::new(basemap));
183 println!("Serving basemap {}", basemap.display());
184 } else {
185 eprintln!(
186 "basemap {} not found; serving demo without a basemap",
187 basemap.display()
188 );
189 }
190
191 app.method_not_allowed_fallback(method::method_not_allowed)
197 .fallback_service(ServeDir::new(demo).not_found_service(method::not_found.into_service()))
198 .layer(middleware::from_fn(request_id::propagate))
199 .with_state(state)
200}
201
202fn get_only<H, T>(handler: H) -> MethodRouter<AppState>
205where
206 H: Handler<T, AppState>,
207 T: 'static,
208{
209 on(MethodFilter::GET, handler).on(MethodFilter::HEAD, method::method_not_allowed)
210}
211
212fn with_id(problem: Problem, request_id: &Option<String>) -> Problem {
214 match request_id {
215 Some(id) => problem.with_request_id(id.clone()),
216 None => problem,
217 }
218}
219
220async fn search(
221 State(state): State<AppState>,
222 request_id: Option<Extension<RequestId>>,
223 params: Result<Query<SearchParams>, axum::extract::rejection::QueryRejection>,
224) -> Result<Json<SearchResponse>, Problem> {
225 let request_id = request_id.map(|Extension(RequestId(id))| id);
226 let Query(params) = params.map_err(|_| {
227 with_id(
228 Problem::malformed_parameter("could not parse query parameters"),
229 &request_id,
230 )
231 })?;
232
233 let query = bounds::validate_query(params.q.as_deref().unwrap_or_default())
234 .map_err(|problem| with_id(problem, &request_id))?;
235 bounds::validate_search_limit(params.limit).map_err(|problem| with_id(problem, &request_id))?;
236
237 let options = TextSearchOptions {
238 query: query.clone(),
239 limit: params.limit,
240 layer: params.layer,
241 };
242 let searcher = Arc::clone(&state.searcher);
243 let hits = task::spawn_blocking(move || searcher.search(options))
244 .await
245 .map_err(|_| with_id(Problem::internal(), &request_id))?
246 .map_err(|error| with_id(classify_search_error(error), &request_id))?;
247
248 Ok(Json(SearchResponse {
249 query,
250 results: hits.into_iter().map(SearchApiResult::from_hit).collect(),
251 }))
252}
253
254async fn autocomplete(
255 State(state): State<AppState>,
256 request_id: Option<Extension<RequestId>>,
257 params: Result<Query<SearchParams>, axum::extract::rejection::QueryRejection>,
258) -> Result<Json<AutocompleteResponse>, Problem> {
259 let request_id = request_id.map(|Extension(RequestId(id))| id);
260 let Query(params) = params.map_err(|_| {
261 with_id(
262 Problem::malformed_parameter("could not parse query parameters"),
263 &request_id,
264 )
265 })?;
266
267 let query = bounds::validate_query_length(params.q.as_deref().unwrap_or_default())
270 .map_err(|problem| with_id(problem, &request_id))?;
271 bounds::validate_autocomplete_limit(params.limit)
272 .map_err(|problem| with_id(problem, &request_id))?;
273
274 let options = TextAutocompleteOptions {
275 query: query.clone(),
276 limit: params.limit,
277 layer: params.layer,
278 };
279 let searcher = Arc::clone(&state.searcher);
280 let hits = task::spawn_blocking(move || searcher.autocomplete(options))
281 .await
282 .map_err(|_| with_id(Problem::internal(), &request_id))?
283 .map_err(|error| with_id(classify_search_error(error), &request_id))?;
284
285 Ok(Json(AutocompleteResponse {
286 query,
287 suggestions: hits.into_iter().map(SearchApiResult::from_hit).collect(),
288 }))
289}
290
291async fn reverse(
292 State(state): State<AppState>,
293 request_id: Option<Extension<RequestId>>,
294 params: Result<Query<ReverseParams>, axum::extract::rejection::QueryRejection>,
295) -> Result<Json<ReverseGeocodeResponse>, Problem> {
296 let request_id = request_id.map(|Extension(RequestId(id))| id);
297 let Query(params) = params.map_err(|_| {
298 with_id(
299 Problem::malformed_parameter("could not parse query parameters"),
300 &request_id,
301 )
302 })?;
303
304 bounds::validate_coords(params.lon, params.lat)
305 .map_err(|problem| with_id(problem, &request_id))?;
306
307 let geocoder = Arc::clone(&state.reverse_geocoder);
308 let response = task::spawn_blocking(move || {
309 geocoder.reverse(ReverseGeocodeOptions {
310 lon: params.lon,
311 lat: params.lat,
312 })
313 })
314 .await
315 .map_err(|_| with_id(Problem::internal(), &request_id))?
316 .map_err(|_| with_id(Problem::internal(), &request_id))?;
317
318 Ok(Json(response))
319}
320
321impl SearchApiResult {
322 fn from_hit(hit: TextSearchHit) -> Self {
323 Self {
324 record_id: hit.record_id,
325 id: hit.record.id,
326 layer: hit.record.layer,
327 label: hit.record.label,
328 score: hit.score,
329 point: hit.record.point.map(SearchApiPoint::from),
330 source: SearchApiSource::from(hit.record.source),
331 }
332 }
333}
334
335impl From<RecordPoint> for SearchApiPoint {
336 fn from(point: RecordPoint) -> Self {
337 Self {
338 lon: point.lon,
339 lat: point.lat,
340 precision: match point.precision {
341 RecordPointPrecision::Point => SearchApiPointPrecision::Point,
342 RecordPointPrecision::Centroid => SearchApiPointPrecision::Centroid,
343 RecordPointPrecision::RepresentativePoint => {
344 SearchApiPointPrecision::RepresentativePoint
345 }
346 },
347 }
348 }
349}
350
351impl From<RecordSource> for SearchApiSource {
352 fn from(source: RecordSource) -> Self {
353 Self {
354 dataset: source.dataset,
355 object_type: source.object_type,
356 object_id: source.object_id,
357 derived_from: source.derived_from,
358 record_count: source.record_count,
359 }
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use crate::pack::RecordSummary;
366 use crate::record::OsmObjectType;
367
368 use super::*;
369
370 #[test]
371 fn api_result_shapes_address_point() {
372 let hit = TextSearchHit {
373 record_id: 7,
374 score: 4.25,
375 record: RecordSummary {
376 id: "osm:node:1".to_string(),
377 layer: "address".to_string(),
378 label: "10 King Street, Toronto".to_string(),
379 point: Some(RecordPoint {
380 lon: -79.3832,
381 lat: 43.6532,
382 precision: RecordPointPrecision::Point,
383 }),
384 source: RecordSource {
385 dataset: "osm".to_string(),
386 object_type: Some(OsmObjectType::Node),
387 object_id: Some(1),
388 derived_from: None,
389 record_count: None,
390 },
391 },
392 };
393
394 let result = SearchApiResult::from_hit(hit);
395
396 assert_eq!(result.record_id, 7);
397 assert_eq!(result.id, "osm:node:1");
398 assert_eq!(result.layer, "address");
399 assert_eq!(result.label, "10 King Street, Toronto");
400 assert_eq!(
401 result.point,
402 Some(SearchApiPoint {
403 lon: -79.3832,
404 lat: 43.6532,
405 precision: SearchApiPointPrecision::Point,
406 })
407 );
408 assert_eq!(result.source.dataset, "osm");
409 assert_eq!(result.source.object_type, Some(OsmObjectType::Node));
410 }
411
412 #[test]
413 fn api_result_uses_representative_point_for_street() {
414 let hit = TextSearchHit {
415 record_id: 12,
416 score: 3.0,
417 record: RecordSummary {
418 id: "osm:way:9".to_string(),
419 layer: "street".to_string(),
420 label: "King Street".to_string(),
421 point: Some(RecordPoint {
422 lon: -79.41,
423 lat: 43.61,
424 precision: RecordPointPrecision::RepresentativePoint,
425 }),
426 source: RecordSource {
427 dataset: "osm".to_string(),
428 object_type: Some(OsmObjectType::Way),
429 object_id: Some(9),
430 derived_from: None,
431 record_count: None,
432 },
433 },
434 };
435
436 let result = SearchApiResult::from_hit(hit);
437
438 assert_eq!(
439 result.point,
440 Some(SearchApiPoint {
441 lon: -79.41,
442 lat: 43.61,
443 precision: SearchApiPointPrecision::RepresentativePoint,
444 })
445 );
446 }
447}
448
449#[cfg(test)]
450mod router_tests {
451 use std::collections::BTreeMap;
452 use std::path::Path;
453
454 use axum::body::{Body, to_bytes};
455 use axum::http::{Request, StatusCode, header};
456 use tower::ServiceExt;
457
458 use super::*;
459 use crate::pack::PackWriter;
460 use crate::record::{
461 AddressComponents, AddressRecord, LocationPrecision, OsmObjectType, SourceProvenance,
462 point_geometry,
463 };
464
465 fn temp_path(name: &str) -> PathBuf {
466 std::env::temp_dir().join(format!("open-geocode-http-{name}-{}", std::process::id()))
467 }
468
469 fn write_pack(dir: &Path) {
470 let _ = std::fs::remove_dir_all(dir);
471 let mut writer = PackWriter::create(dir).expect("writer");
472 writer
473 .write(
474 &AddressRecord {
475 address: AddressComponents {
476 number: "10".to_string(),
477 street: Some("King Street".to_string()),
478 place: None,
479 unit: None,
480 locality: Some("Toronto".to_string()),
481 region: None,
482 postcode: Some("M5V 1A1".to_string()),
483 country: None,
484 },
485 geometry: point_geometry(-79.0, 43.0),
486 location_precision: LocationPrecision::Point,
487 source: SourceProvenance {
488 dataset: "osm".to_string(),
489 object_type: OsmObjectType::Node,
490 object_id: 1,
491 tags: Some(BTreeMap::new()),
492 },
493 }
494 .into(),
495 None,
496 )
497 .expect("write address");
498 writer.finish().expect("finish");
499 }
500
501 fn state_for(pack_dir: &Path, ready: bool) -> AppState {
502 let state = AppState::open(pack_dir).expect("open shared pack");
503 state.ready.store(ready, Ordering::Release);
504 state
505 }
506
507 fn request(method: &str, uri: &str) -> Request<Body> {
508 Request::builder()
509 .method(method)
510 .uri(uri)
511 .body(Body::empty())
512 .unwrap()
513 }
514
515 struct Reply {
516 status: StatusCode,
517 headers: axum::http::HeaderMap,
518 body: Vec<u8>,
519 }
520
521 impl Reply {
522 fn content_type(&self) -> &str {
523 self.headers
524 .get(header::CONTENT_TYPE)
525 .and_then(|value| value.to_str().ok())
526 .unwrap_or_default()
527 }
528
529 fn json(&self) -> serde_json::Value {
530 serde_json::from_slice(&self.body).expect("response body is JSON")
531 }
532
533 fn request_id(&self) -> Option<String> {
534 self.headers
535 .get("x-request-id")
536 .and_then(|value| value.to_str().ok())
537 .map(str::to_string)
538 }
539 }
540
541 async fn send(router: Router, request: Request<Body>) -> Reply {
542 let response = router.oneshot(request).await.expect("router response");
543 let status = response.status();
544 let headers = response.headers().clone();
545 let body = to_bytes(response.into_body(), usize::MAX)
546 .await
547 .expect("read body")
548 .to_vec();
549 Reply {
550 status,
551 headers,
552 body,
553 }
554 }
555
556 #[tokio::test]
557 async fn health_endpoints_reflect_readiness() {
558 let pack = temp_path("health");
559 write_pack(&pack);
560 let demo = temp_path("health-demo");
561 let no_basemap = temp_path("health-no-basemap");
562
563 let router = build_router(state_for(&pack, true), &demo, &no_basemap);
564 assert_eq!(
565 send(router.clone(), request("GET", "/healthz"))
566 .await
567 .status,
568 StatusCode::OK
569 );
570 assert_eq!(
571 send(router, request("GET", "/readyz")).await.status,
572 StatusCode::OK
573 );
574
575 let not_ready = build_router(state_for(&pack, false), &demo, &no_basemap);
576 assert_eq!(
577 send(not_ready, request("GET", "/readyz")).await.status,
578 StatusCode::SERVICE_UNAVAILABLE
579 );
580
581 let _ = std::fs::remove_dir_all(&pack);
582 }
583
584 #[tokio::test]
585 async fn search_enforces_query_and_limit_bounds() {
586 let pack = temp_path("search-bounds");
587 write_pack(&pack);
588 let demo = temp_path("search-demo");
589 let no_basemap = temp_path("search-no-basemap");
590 let router = build_router(state_for(&pack, true), &demo, &no_basemap);
591
592 let reply = send(router.clone(), request("GET", "/search?q=")).await;
594 assert_eq!(reply.status, StatusCode::BAD_REQUEST);
595 assert_eq!(reply.content_type(), "application/problem+json");
596 assert_eq!(reply.json()["error_code"], "invalid_query");
597
598 let long = "a".repeat(300);
600 let reply = send(router.clone(), request("GET", &format!("/search?q={long}"))).await;
601 assert_eq!(reply.status, StatusCode::BAD_REQUEST);
602
603 let reply = send(
605 router.clone(),
606 request("GET", "/search?q=king&limit=999999"),
607 )
608 .await;
609 assert_eq!(reply.status, StatusCode::BAD_REQUEST);
610 assert_eq!(reply.json()["invalid_params"][0]["name"], "limit");
611
612 let reply = send(router.clone(), request("GET", "/search?q=king&limit=abc")).await;
614 assert_eq!(reply.status, StatusCode::BAD_REQUEST);
615 assert_eq!(reply.json()["error_code"], "malformed_parameter");
616
617 let reply = send(router, request("GET", "/search?q=King%20Street&limit=5")).await;
619 assert_eq!(reply.status, StatusCode::OK);
620 assert_eq!(reply.content_type(), "application/json");
621
622 let _ = std::fs::remove_dir_all(&pack);
623 }
624
625 #[tokio::test]
626 async fn reverse_validates_coordinates() {
627 let pack = temp_path("reverse-bounds");
628 write_pack(&pack);
629 let demo = temp_path("reverse-demo");
630 let no_basemap = temp_path("reverse-no-basemap");
631 let router = build_router(state_for(&pack, true), &demo, &no_basemap);
632
633 let reply = send(router.clone(), request("GET", "/reverse?lon=999&lat=0")).await;
634 assert_eq!(reply.status, StatusCode::BAD_REQUEST);
635 assert_eq!(reply.json()["invalid_params"][0]["name"], "lon");
636
637 let reply = send(router.clone(), request("GET", "/reverse?lon=0&lat=999")).await;
638 assert_eq!(reply.status, StatusCode::BAD_REQUEST);
639 assert_eq!(reply.json()["invalid_params"][0]["name"], "lat");
640
641 let reply = send(router, request("GET", "/reverse?lon=-79.0&lat=43.0")).await;
642 assert_eq!(reply.status, StatusCode::OK);
643
644 let _ = std::fs::remove_dir_all(&pack);
645 }
646
647 #[tokio::test]
648 async fn methods_unknown_paths_and_request_ids() {
649 let pack = temp_path("methods");
650 write_pack(&pack);
651 let demo = temp_path("methods-demo");
652 let no_basemap = temp_path("methods-no-basemap");
653 let router = build_router(state_for(&pack, true), &demo, &no_basemap);
654
655 let reply = send(router.clone(), request("POST", "/search")).await;
657 assert_eq!(reply.status, StatusCode::METHOD_NOT_ALLOWED);
658 assert_eq!(reply.content_type(), "application/problem+json");
659 assert_eq!(
660 reply
661 .headers
662 .get(header::ALLOW)
663 .and_then(|v| v.to_str().ok()),
664 Some("GET")
665 );
666
667 let reply = send(router.clone(), request("HEAD", "/search")).await;
669 assert_eq!(reply.status, StatusCode::METHOD_NOT_ALLOWED);
670
671 let reply = send(router.clone(), request("GET", "/does-not-exist")).await;
673 assert_eq!(reply.status, StatusCode::NOT_FOUND);
674 assert_eq!(reply.content_type(), "application/problem+json");
675 assert_eq!(reply.json()["error_code"], "not_found");
676
677 let reply = send(router.clone(), request("GET", "/healthz")).await;
679 assert!(reply.request_id().is_some());
680
681 let mut tagged = request("GET", "/search?q=");
682 tagged
683 .headers_mut()
684 .insert("cf-ray", header::HeaderValue::from_static("ray-abc-123"));
685 let reply = send(router, tagged).await;
686 assert_eq!(reply.request_id().as_deref(), Some("ray-abc-123"));
687 assert_eq!(reply.json()["request_id"], "ray-abc-123");
688
689 let _ = std::fs::remove_dir_all(&pack);
690 }
691
692 #[tokio::test]
693 async fn basemap_is_served_raw_with_range_support() {
694 let pack = temp_path("basemap");
699 write_pack(&pack);
700 let demo = temp_path("basemap-demo");
701 let basemap = temp_path("basemap.pmtiles");
702 std::fs::write(&basemap, vec![0u8; 600_000]).expect("write basemap");
703
704 let router = build_router(state_for(&pack, true), &demo, &basemap);
705
706 let mut req = request("GET", "/basemap.pmtiles");
708 req.headers_mut().insert(
709 header::RANGE,
710 header::HeaderValue::from_static("bytes=0-511999"),
711 );
712 let reply = send(router.clone(), req).await;
713 assert_eq!(reply.status, StatusCode::PARTIAL_CONTENT);
714 assert_eq!(reply.body.len(), 512_000);
715
716 let reply = send(router.clone(), request("GET", "/basemap.pmtiles")).await;
718 assert_eq!(reply.status, StatusCode::OK);
719 assert_eq!(reply.body.len(), 600_000);
720
721 let reply = send(router, request("HEAD", "/basemap.pmtiles")).await;
723 assert_eq!(reply.status, StatusCode::OK);
724
725 let _ = std::fs::remove_dir_all(&pack);
726 let _ = std::fs::remove_file(&basemap);
727 }
728}