Skip to main content

oxigdal_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
171            .route("/stats", get(stats_handler))
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 OxiGDAL 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                .await
266                .map_err(|e| ServerError::Http(e.to_string()))
267        };
268
269        if self.config.server.metrics_enabled {
270            let metrics_bind_addr = format!(
271                "{}:{}",
272                self.config.server.host, self.config.server.metrics_port
273            );
274            let metrics_app = self.build_metrics_router();
275
276            let metrics_listener = tokio::net::TcpListener::bind(&metrics_bind_addr)
277                .await
278                .map_err(|e| {
279                    ServerError::Http(format!(
280                        "Failed to bind metrics listener to {}: {}",
281                        metrics_bind_addr, e
282                    ))
283                })?;
284
285            info!("Metrics listening on http://{}/metrics", metrics_bind_addr);
286
287            let metrics_future = async {
288                axum::serve(metrics_listener, metrics_app)
289                    .await
290                    .map_err(|e| ServerError::Http(e.to_string()))
291            };
292
293            let (app_result, metrics_result) = tokio::join!(app_future, metrics_future);
294            app_result?;
295            metrics_result?;
296        } else {
297            info!("Metrics endpoint disabled (server.metrics_enabled = false)");
298            app_future.await?;
299        }
300
301        Ok(())
302    }
303
304    /// Get the service URL
305    fn get_service_url(&self) -> String {
306        format!(
307            "http://{}:{}",
308            self.config.server.host, self.config.server.port
309        )
310    }
311
312    /// Get the dataset registry
313    pub fn registry(&self) -> &DatasetRegistry {
314        &self.registry
315    }
316
317    /// Get the tile cache
318    pub fn cache(&self) -> &TileCache {
319        &self.cache
320    }
321
322    /// Get the configuration
323    pub fn config(&self) -> &Config {
324        &self.config
325    }
326}
327
328/// Timeout middleware that wraps requests with a timeout
329async fn timeout_middleware(
330    req: Request,
331    next: Next,
332    duration: Duration,
333) -> Result<Response, StatusCode> {
334    match tokio::time::timeout(duration, next.run(req)).await {
335        Ok(response) => Ok(response),
336        Err(_) => {
337            error!("Request timeout after {:?}", duration);
338            Err(StatusCode::GATEWAY_TIMEOUT)
339        }
340    }
341}
342
343/// Home page handler
344async fn home_handler() -> Html<&'static str> {
345    Html(
346        r#"<!DOCTYPE html>
347<html>
348<head>
349    <title>OxiGDAL Tile Server</title>
350    <style>
351        body {
352            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
353            max-width: 800px;
354            margin: 50px auto;
355            padding: 20px;
356            line-height: 1.6;
357        }
358        h1 { color: #2c3e50; }
359        h2 { color: #34495e; margin-top: 30px; }
360        code {
361            background: #f4f4f4;
362            padding: 2px 6px;
363            border-radius: 3px;
364            font-family: 'Courier New', monospace;
365        }
366        .endpoint {
367            background: #ecf0f1;
368            padding: 10px;
369            margin: 10px 0;
370            border-left: 4px solid #3498db;
371        }
372        a { color: #3498db; text-decoration: none; }
373        a:hover { text-decoration: underline; }
374    </style>
375</head>
376<body>
377    <h1>OxiGDAL Tile Server</h1>
378    <p>WMS/WMTS tile server powered by OxiGDAL - Pure Rust geospatial data access library.</p>
379
380    <h2>Available Endpoints</h2>
381
382    <div class="endpoint">
383        <h3>WMS (Web Map Service)</h3>
384        <p><a href="/wms?SERVICE=WMS&REQUEST=GetCapabilities">GetCapabilities</a></p>
385        <p>GetMap: <code>/wms?SERVICE=WMS&REQUEST=GetMap&LAYERS=layer&BBOX=...</code></p>
386    </div>
387
388    <div class="endpoint">
389        <h3>WMTS (Web Map Tile Service)</h3>
390        <p><a href="/wmts?SERVICE=WMTS&REQUEST=GetCapabilities">GetCapabilities</a></p>
391        <p>GetTile: <code>/wmts/1.0.0/{layer}/{tileMatrixSet}/{z}/{x}/{y}.png</code></p>
392    </div>
393
394    <div class="endpoint">
395        <h3>XYZ Tiles</h3>
396        <p>Tiles: <code>/tiles/{layer}/{z}/{x}/{y}.png</code></p>
397        <p>TileJSON: <code>/tiles/{layer}/tilejson</code></p>
398    </div>
399
400    <h2>Server Status</h2>
401    <p><a href="/health">Health Check</a> | <a href="/stats">Cache Statistics</a></p>
402
403    <h2>Documentation</h2>
404    <p>For more information, visit the <a href="https://github.com/cool-japan/oxigdal">OxiGDAL repository</a>.</p>
405</body>
406</html>
407"#,
408    )
409}
410
411/// Health check handler
412async fn health_handler() -> Response {
413    (
414        StatusCode::OK,
415        [(header::CONTENT_TYPE, "application/json")],
416        r#"{"status":"healthy","service":"oxigdal-tile-server"}"#,
417    )
418        .into_response()
419}
420
421/// Cache statistics handler
422async fn stats_handler() -> Response {
423    // This is a placeholder - in a real implementation, we'd need to access the cache
424    // through shared state
425    let stats = serde_json::json!({
426        "status": "ok",
427        "message": "Cache statistics endpoint - requires state injection"
428    });
429
430    (
431        StatusCode::OK,
432        [(header::CONTENT_TYPE, "application/json")],
433        serde_json::to_string_pretty(&stats).unwrap_or_default(),
434    )
435        .into_response()
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_server_creation() {
444        let config = Config::default_config();
445        let result = TileServer::new(config);
446
447        // Server creation should succeed with default config
448        // (even though it has no layers)
449        assert!(result.is_ok());
450    }
451}