Skip to main content

open_geocode/
runtime.rs

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    /// True once the Pack is loaded and the Runtime can answer API traffic
62    /// (ADR 0017 Decision 28).
63    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
152/// Assemble the public-demo router with its boundary policy: method-restricted
153/// routes (ADR 0017 Decision 27), Problem Details 404/405 fallbacks
154/// (Decisions 27, 37), and request-id propagation (Decision 30). The basemap is
155/// served raw with no guard — in the deployed topology PMTiles is served from
156/// Cloudflare R2, not the Runtime (Decisions 6/35a, amended 2026-06-03).
157///
158/// Split out from [`serve`] so it can be exercised without binding a socket.
159pub(crate) fn build_router(state: AppState, demo: &Path, basemap: &Path) -> Router {
160    // Read-only API and health routes: GET only (Decision 27). axum would
161    // otherwise route HEAD to the GET handler automatically, so `get_only`
162    // registers an explicit HEAD responder that returns 405. Other methods fall
163    // through to `method_not_allowed_fallback`.
164    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    // The basemap PMTiles lives with the other data artifacts (not in the demo
172    // dir), so serve it explicitly. ServeFile honors HTTP range requests, which
173    // is what the PMTiles client uses. Skip it if the file is absent so a fresh
174    // clone without a basemap still serves the API and demo.
175    //
176    // This is served raw and is a local-dev convenience only. In the public
177    // demo the basemap is served from Cloudflare R2, not the Runtime (ADR 0017
178    // Decisions 6/7/8, amended 2026-06-03); R2's free egress makes PMTiles
179    // abuse protection a non-issue. The bare binary hands back the file with
180    // native range support and no policy.
181    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    // ServeDir keeps local-dev static serving (Decision 10) but its built-in 404
192    // is replaced with a Problem Details 404 so unknown paths don't fall through
193    // to a static-file response (Decision 37). `method_not_allowed_fallback`
194    // turns method mismatches into a Problem 405 instead of an empty body. The
195    // request-id layer is outermost so it also wraps both fallbacks.
196    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
202/// A GET-only route that also rejects HEAD with 405, defeating axum's default
203/// HEAD-to-GET routing so the public method surface stays exactly GET.
204fn 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
212/// Attach the request id (if present) to a Problem so it appears in the body.
213fn 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    // Autocomplete tolerates an empty/short query (it returns no suggestions), so
268    // only bound the length here; the searcher caps the result count internally.
269    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        // Empty query -> 400 invalid_query, Problem Details.
593        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        // Over-length query -> 400.
599        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        // limit above the cap -> 400 with invalid_params naming "limit".
604        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        // Malformed limit type -> 400 malformed_parameter.
613        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        // A valid query succeeds.
618        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        // POST to a GET-only route -> 405 with Allow: GET.
656        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        // HEAD is not part of the API method surface (Decision 27) -> 405.
668        let reply = send(router.clone(), request("HEAD", "/search")).await;
669        assert_eq!(reply.status, StatusCode::METHOD_NOT_ALLOWED);
670
671        // Unknown path -> 404 Problem Details, not a static-file response.
672        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        // Every response carries a request id; an inbound cf-ray is honored.
678        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        // The runtime serves the basemap raw: range requests work natively and
695        // there is deliberately no full-file fetch guard here (that is a
696        // deployment-layer concern, ADR 0017 Decision 35a). The bare binary hands
697        // back exactly what was asked for.
698        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        // A bounded range is honored as a 206 with exactly the requested bytes.
707        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        // A Range-less GET returns the whole file (200) — raw, unguarded.
717        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        // HEAD works too.
722        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}