Skip to main content

oxigeo_server/
server.rs

1//! HTTP server implementation
2//!
3//! Sets up the Axum web server with all routes and middleware for serving tiles.
4
5use crate::cache::{TileCache, TileCacheConfig};
6use crate::config::Config;
7use crate::dataset_registry::DatasetRegistry;
8use crate::handlers::{
9    TileState, WmsState, WmtsState, get_feature_info, get_map, get_tile, get_tile_kvp,
10    get_tile_rest, get_tilejson, wms_get_capabilities, wmts_get_capabilities,
11};
12use crate::metrics::{AppMetrics, metrics_handler, track_http_metrics};
13use axum::{
14    Router,
15    extract::{DefaultBodyLimit, Request},
16    http::{Method, StatusCode, header},
17    middleware::{self, Next},
18    response::{Html, IntoResponse, Response},
19    routing::get,
20};
21use std::sync::Arc;
22use std::time::Duration;
23use thiserror::Error;
24use tower::ServiceBuilder;
25use tower_http::{
26    cors::{Any, CorsLayer},
27    trace::TraceLayer,
28};
29use tracing::{error, info};
30
31/// Server errors
32#[derive(Debug, Error)]
33pub enum ServerError {
34    /// Configuration error
35    #[error("Configuration error: {0}")]
36    Config(String),
37
38    /// Registry error
39    #[error("Registry error: {0}")]
40    Registry(#[from] crate::dataset_registry::RegistryError),
41
42    /// HTTP server error
43    #[error("HTTP server error: {0}")]
44    Http(String),
45
46    /// I/O error
47    #[error("I/O error: {0}")]
48    Io(#[from] std::io::Error),
49}
50
51/// Result type for server operations
52pub type ServerResult<T> = Result<T, ServerError>;
53
54/// Tile server instance
55pub struct TileServer {
56    /// Server configuration
57    config: Config,
58
59    /// Dataset registry
60    registry: DatasetRegistry,
61
62    /// Tile cache
63    cache: TileCache,
64
65    /// Prometheus metrics registry
66    metrics: AppMetrics,
67}
68
69impl TileServer {
70    /// Create a new tile server
71    pub fn new(config: Config) -> ServerResult<Self> {
72        // Create dataset registry
73        let registry = DatasetRegistry::new();
74
75        // Register all configured layers
76        registry
77            .register_layers(config.layers.clone())
78            .map_err(ServerError::Registry)?;
79
80        info!("Registered {} layers", registry.layer_count());
81
82        // Create tile cache
83        let cache_config = TileCacheConfig {
84            max_memory_bytes: config.cache.memory_size_mb * 1024 * 1024,
85            disk_cache_dir: config.cache.disk_cache.clone(),
86            ttl: Duration::from_secs(config.cache.ttl_seconds),
87            enable_stats: config.cache.enable_stats,
88            compression: config.cache.compression,
89        };
90
91        let cache = TileCache::new(cache_config);
92
93        Ok(Self {
94            config,
95            registry,
96            cache,
97            metrics: AppMetrics::new(),
98        })
99    }
100
101    /// Build the Axum router
102    pub fn build_router(&self) -> Router {
103        let service_url = format!(
104            "http://{}:{}",
105            self.config.server.host, self.config.server.port
106        );
107
108        // Create shared state for WMS
109        let wms_state = Arc::new(WmsState {
110            registry: self.registry.clone(),
111            cache: self.cache.clone(),
112            service_url: service_url.clone(),
113            service_title: self.config.metadata.title.clone(),
114            service_abstract: self.config.metadata.abstract_.clone(),
115        });
116
117        // Create shared state for WMTS
118        let wmts_state = Arc::new(WmtsState {
119            registry: self.registry.clone(),
120            cache: self.cache.clone(),
121            service_url: service_url.clone(),
122            service_title: self.config.metadata.title.clone(),
123            service_abstract: self.config.metadata.abstract_.clone(),
124        });
125
126        // Create shared state for XYZ tiles
127        let tile_state = Arc::new(TileState {
128            registry: self.registry.clone(),
129            cache: self.cache.clone(),
130        });
131
132        // Build CORS layer
133        let cors = if self.config.server.enable_cors {
134            let mut cors = CorsLayer::new()
135                .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
136                .allow_headers([header::CONTENT_TYPE, header::ACCEPT]);
137
138            cors = if self.config.server.cors_origins.is_empty() {
139                cors.allow_origin(Any)
140            } else {
141                let origins: Vec<_> = self
142                    .config
143                    .server
144                    .cors_origins
145                    .iter()
146                    .filter_map(|o| o.parse().ok())
147                    .collect();
148                cors.allow_origin(origins)
149            };
150
151            cors
152        } else {
153            CorsLayer::permissive()
154        };
155
156        // Build middleware stack
157        let middleware = ServiceBuilder::new()
158            .layer(TraceLayer::new_for_http())
159            .layer(cors)
160            .layer(DefaultBodyLimit::max(self.config.server.max_request_size));
161
162        // Build routes with timeout middleware
163        let timeout_duration = Duration::from_secs(self.config.server.timeout_seconds);
164
165        Router::new()
166            // Home/landing page
167            .route("/", get(home_handler))
168            // Health check
169            .route("/health", get(health_handler))
170            // Cache stats (reads the real, shared TileCache statistics)
171            .route("/stats", get(stats_handler).with_state(self.cache.clone()))
172            // WMS endpoints
173            .route("/wms", get(get_map).with_state(wms_state.clone()))
174            .route(
175                "/wms/capabilities",
176                get(wms_get_capabilities).with_state(wms_state.clone()),
177            )
178            .route(
179                "/wms/feature_info",
180                get(get_feature_info).with_state(wms_state),
181            )
182            // WMTS endpoints
183            .route("/wmts", get(get_tile_kvp).with_state(wmts_state.clone()))
184            .route(
185                "/wmts/capabilities",
186                get(wmts_get_capabilities).with_state(wmts_state.clone()),
187            )
188            .route(
189                // The file extension (e.g. `.png`) is part of the `{tile_col}` capture, not
190                // a literal route suffix - matchit (axum's router) allows only one dynamic
191                // parameter per path segment. See `handlers::wmts::parse_tile_col_and_format`.
192                "/wmts/1.0.0/{layer}/{tile_matrix_set}/{tile_matrix}/{tile_row}/{tile_col}",
193                get(get_tile_rest).with_state(wmts_state),
194            )
195            // XYZ tile endpoints
196            .route(
197                "/tiles/{layer}/{z}/{x}/{y}",
198                get(get_tile).with_state(tile_state.clone()),
199            )
200            .route(
201                "/tiles/{layer}/tilejson",
202                get(get_tilejson).with_state(tile_state),
203            )
204            // Recorded after routing so `MatchedPath` (route template, not raw path with
205            // high-cardinality tile coordinates) is available for metric labels.
206            .route_layer(middleware::from_fn_with_state(
207                self.metrics.clone(),
208                track_http_metrics,
209            ))
210            .layer(middleware)
211            .layer(middleware::from_fn(move |req, next| {
212                timeout_middleware(req, next, timeout_duration)
213            }))
214    }
215
216    /// Build the dedicated metrics router, served on its own port (see
217    /// `ServerConfig::metrics_port`) so it can be scraped independently of the public
218    /// WMS/WMTS/tile surface, matching the `metrics` container/service port that
219    /// `k8s/deployment.yaml` and `monitoring/prometheus.yml` already assume.
220    fn build_metrics_router(&self) -> Router {
221        Router::new()
222            .route("/metrics", get(metrics_handler))
223            .with_state((self.metrics.clone(), self.cache.clone()))
224    }
225
226    /// Start the server
227    ///
228    /// Binds the public WMS/WMTS/tile router and, when
229    /// `ServerConfig::metrics_enabled` is set, a second listener carrying only
230    /// `GET /metrics` on `ServerConfig::metrics_port`. Both listeners are served
231    /// concurrently; if either one fails to bind or serve, `serve()` returns the first
232    /// error observed and the other listener is dropped.
233    pub async fn serve(self) -> ServerResult<()> {
234        let bind_addr = self.config.bind_address();
235        info!("Starting OxiGeo tile server on {}", bind_addr);
236        info!("Service URL: {}", self.get_service_url());
237        info!("Workers: {}", self.config.server.workers);
238        info!("Cache: {} MB memory", self.config.cache.memory_size_mb);
239
240        if let Some(ref disk_cache) = self.config.cache.disk_cache {
241            info!("Disk cache: {}", disk_cache.display());
242        }
243
244        // Build router
245        let app = self.build_router();
246
247        // Create TCP listener
248        let listener = tokio::net::TcpListener::bind(&bind_addr)
249            .await
250            .map_err(|e| ServerError::Http(format!("Failed to bind to {}: {}", bind_addr, e)))?;
251
252        info!("Server listening on {}", bind_addr);
253        info!("Available endpoints:");
254        info!("  - WMS:  http://{}/wms", bind_addr);
255        info!("  - WMTS: http://{}/wmts", bind_addr);
256        info!(
257            "  - XYZ:  http://{}/tiles/{{layer}}/{{z}}/{{x}}/{{y}}.png",
258            bind_addr
259        );
260        info!("  - Health: http://{}/health", bind_addr);
261        info!("  - Stats: http://{}/stats", bind_addr);
262
263        let app_future = async {
264            axum::serve(listener, app)
265                .with_graceful_shutdown(shutdown_signal())
266                .await
267                .map_err(|e| ServerError::Http(e.to_string()))
268        };
269
270        if self.config.server.metrics_enabled {
271            let metrics_bind_addr = format!(
272                "{}:{}",
273                self.config.server.host, self.config.server.metrics_port
274            );
275            let metrics_app = self.build_metrics_router();
276
277            let metrics_listener = tokio::net::TcpListener::bind(&metrics_bind_addr)
278                .await
279                .map_err(|e| {
280                    ServerError::Http(format!(
281                        "Failed to bind metrics listener to {}: {}",
282                        metrics_bind_addr, e
283                    ))
284                })?;
285
286            info!("Metrics listening on http://{}/metrics", metrics_bind_addr);
287
288            let metrics_future = async {
289                axum::serve(metrics_listener, metrics_app)
290                    .with_graceful_shutdown(shutdown_signal())
291                    .await
292                    .map_err(|e| ServerError::Http(e.to_string()))
293            };
294
295            let (app_result, metrics_result) = tokio::join!(app_future, metrics_future);
296            app_result?;
297            metrics_result?;
298        } else {
299            info!("Metrics endpoint disabled (server.metrics_enabled = false)");
300            app_future.await?;
301        }
302
303        Ok(())
304    }
305
306    /// Get the service URL
307    fn get_service_url(&self) -> String {
308        format!(
309            "http://{}:{}",
310            self.config.server.host, self.config.server.port
311        )
312    }
313
314    /// Get the dataset registry
315    pub fn registry(&self) -> &DatasetRegistry {
316        &self.registry
317    }
318
319    /// Get the tile cache
320    pub fn cache(&self) -> &TileCache {
321        &self.cache
322    }
323
324    /// Get the configuration
325    pub fn config(&self) -> &Config {
326        &self.config
327    }
328}
329
330/// Timeout middleware that wraps requests with a timeout
331async fn timeout_middleware(
332    req: Request,
333    next: Next,
334    duration: Duration,
335) -> Result<Response, StatusCode> {
336    match tokio::time::timeout(duration, next.run(req)).await {
337        Ok(response) => Ok(response),
338        Err(_) => {
339            error!("Request timeout after {:?}", duration);
340            Err(StatusCode::GATEWAY_TIMEOUT)
341        }
342    }
343}
344
345/// Home page handler
346async fn home_handler() -> Html<&'static str> {
347    Html(
348        r#"<!DOCTYPE html>
349<html>
350<head>
351    <title>OxiGeo Tile Server</title>
352    <style>
353        body {
354            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
355            max-width: 800px;
356            margin: 50px auto;
357            padding: 20px;
358            line-height: 1.6;
359        }
360        h1 { color: #2c3e50; }
361        h2 { color: #34495e; margin-top: 30px; }
362        code {
363            background: #f4f4f4;
364            padding: 2px 6px;
365            border-radius: 3px;
366            font-family: 'Courier New', monospace;
367        }
368        .endpoint {
369            background: #ecf0f1;
370            padding: 10px;
371            margin: 10px 0;
372            border-left: 4px solid #3498db;
373        }
374        a { color: #3498db; text-decoration: none; }
375        a:hover { text-decoration: underline; }
376    </style>
377</head>
378<body>
379    <h1>OxiGeo Tile Server</h1>
380    <p>WMS/WMTS tile server powered by OxiGeo - Pure Rust geospatial data access library.</p>
381
382    <h2>Available Endpoints</h2>
383
384    <div class="endpoint">
385        <h3>WMS (Web Map Service)</h3>
386        <p><a href="/wms?SERVICE=WMS&REQUEST=GetCapabilities">GetCapabilities</a></p>
387        <p>GetMap: <code>/wms?SERVICE=WMS&REQUEST=GetMap&LAYERS=layer&BBOX=...</code></p>
388    </div>
389
390    <div class="endpoint">
391        <h3>WMTS (Web Map Tile Service)</h3>
392        <p><a href="/wmts?SERVICE=WMTS&REQUEST=GetCapabilities">GetCapabilities</a></p>
393        <p>GetTile: <code>/wmts/1.0.0/{layer}/{tileMatrixSet}/{z}/{x}/{y}.png</code></p>
394    </div>
395
396    <div class="endpoint">
397        <h3>XYZ Tiles</h3>
398        <p>Tiles: <code>/tiles/{layer}/{z}/{x}/{y}.png</code></p>
399        <p>TileJSON: <code>/tiles/{layer}/tilejson</code></p>
400    </div>
401
402    <h2>Server Status</h2>
403    <p><a href="/health">Health Check</a> | <a href="/stats">Cache Statistics</a></p>
404
405    <h2>Documentation</h2>
406    <p>For more information, visit the <a href="https://github.com/cool-japan/oxigeo">OxiGeo repository</a>.</p>
407</body>
408</html>
409"#,
410    )
411}
412
413/// Health check handler
414async fn health_handler() -> Response {
415    (
416        StatusCode::OK,
417        [(header::CONTENT_TYPE, "application/json")],
418        r#"{"status":"healthy","service":"oxigeo-tile-server"}"#,
419    )
420        .into_response()
421}
422
423/// Cache statistics handler
424///
425/// Reports the real hit/miss/eviction/expiration counters, disk I/O counts,
426/// entry count and memory usage from the shared [`TileCache`].
427async fn stats_handler(axum::extract::State(cache): axum::extract::State<TileCache>) -> Response {
428    let stats = cache.stats();
429
430    let body = serde_json::json!({
431        "status": "ok",
432        "hits": stats.hits,
433        "misses": stats.misses,
434        "hit_rate": stats.hit_rate(),
435        "entry_count": stats.entry_count,
436        "total_size_bytes": stats.total_size,
437        "avg_entry_size_bytes": stats.avg_entry_size(),
438        "evictions": stats.evictions,
439        "expirations": stats.expirations,
440        "disk_reads": stats.disk_reads,
441        "disk_writes": stats.disk_writes,
442        "put_failures": stats.put_failures,
443    });
444
445    (
446        StatusCode::OK,
447        [(header::CONTENT_TYPE, "application/json")],
448        serde_json::to_string_pretty(&body).unwrap_or_default(),
449    )
450        .into_response()
451}
452
453/// Build the shutdown-signal future used for graceful shutdown.
454///
455/// Resolves when the process receives SIGINT (ctrl-c) or, on Unix, SIGTERM
456/// (sent by container orchestrators such as Kubernetes during a rollout). This
457/// lets in-flight requests drain instead of being hard-killed mid-response.
458async fn shutdown_signal() {
459    let ctrl_c = async {
460        if let Err(e) = tokio::signal::ctrl_c().await {
461            error!("Failed to install ctrl-c handler: {}", e);
462        }
463    };
464
465    #[cfg(unix)]
466    let terminate = async {
467        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
468            Ok(mut sig) => {
469                sig.recv().await;
470            }
471            Err(e) => error!("Failed to install SIGTERM handler: {}", e),
472        }
473    };
474
475    #[cfg(not(unix))]
476    let terminate = std::future::pending::<()>();
477
478    tokio::select! {
479        _ = ctrl_c => {},
480        _ = terminate => {},
481    }
482
483    info!("Shutdown signal received; draining in-flight requests");
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn test_server_creation() {
492        let config = Config::default_config();
493        let result = TileServer::new(config);
494
495        // Server creation should succeed with default config
496        // (even though it has no layers)
497        assert!(result.is_ok());
498    }
499}