1use 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#[derive(Debug, Error)]
33pub enum ServerError {
34 #[error("Configuration error: {0}")]
36 Config(String),
37
38 #[error("Registry error: {0}")]
40 Registry(#[from] crate::dataset_registry::RegistryError),
41
42 #[error("HTTP server error: {0}")]
44 Http(String),
45
46 #[error("I/O error: {0}")]
48 Io(#[from] std::io::Error),
49}
50
51pub type ServerResult<T> = Result<T, ServerError>;
53
54pub struct TileServer {
56 config: Config,
58
59 registry: DatasetRegistry,
61
62 cache: TileCache,
64
65 metrics: AppMetrics,
67}
68
69impl TileServer {
70 pub fn new(config: Config) -> ServerResult<Self> {
72 let registry = DatasetRegistry::new();
74
75 registry
77 .register_layers(config.layers.clone())
78 .map_err(ServerError::Registry)?;
79
80 info!("Registered {} layers", registry.layer_count());
81
82 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 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 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 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 let tile_state = Arc::new(TileState {
128 registry: self.registry.clone(),
129 cache: self.cache.clone(),
130 });
131
132 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 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 let timeout_duration = Duration::from_secs(self.config.server.timeout_seconds);
164
165 Router::new()
166 .route("/", get(home_handler))
168 .route("/health", get(health_handler))
170 .route("/stats", get(stats_handler).with_state(self.cache.clone()))
172 .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 .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 "/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 .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 .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 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 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 let app = self.build_router();
246
247 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 fn get_service_url(&self) -> String {
308 format!(
309 "http://{}:{}",
310 self.config.server.host, self.config.server.port
311 )
312 }
313
314 pub fn registry(&self) -> &DatasetRegistry {
316 &self.registry
317 }
318
319 pub fn cache(&self) -> &TileCache {
321 &self.cache
322 }
323
324 pub fn config(&self) -> &Config {
326 &self.config
327 }
328}
329
330async 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
345async 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
413async 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
423async 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
453async 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 assert!(result.is_ok());
498 }
499}