wsi_streamer/server/
handlers.rs

1//! HTTP request handlers for the WSI Streamer tile API.
2//!
3//! This module contains the Axum handlers for serving tiles and health checks.
4//!
5//! # Endpoints
6//!
7//! - `GET /tiles/{slide_id}/{level}/{x}/{y}.jpg` - Serve a tile
8//! - `GET /health` - Health check endpoint
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use axum::{
14    extract::{Path, Query, State},
15    http::{header, HeaderMap, StatusCode},
16    response::{Html, IntoResponse, Response},
17    Json,
18};
19use serde::{Deserialize, Serialize};
20use tracing::{debug, error, warn};
21
22use crate::error::{FormatError, IoError, TiffError, TileError};
23use crate::slide::SlideSource;
24use crate::tile::{TileRequest, TileService, DEFAULT_JPEG_QUALITY};
25
26use super::auth::SignedUrlAuth;
27
28// =============================================================================
29// Application State
30// =============================================================================
31
32/// Shared application state containing the tile service.
33///
34/// This is passed to all handlers via Axum's State extractor.
35pub struct AppState<S: SlideSource> {
36    /// The tile service for processing tile requests
37    pub tile_service: Arc<TileService<S>>,
38
39    /// Default cache control max-age in seconds (defaults to 1 hour)
40    pub cache_max_age: u32,
41
42    /// Authentication configuration for generating signed URLs in the viewer
43    pub auth: Option<SignedUrlAuth>,
44}
45
46impl<S: SlideSource> AppState<S> {
47    /// Create a new application state with the given tile service.
48    pub fn new(tile_service: TileService<S>) -> Self {
49        Self {
50            tile_service: Arc::new(tile_service),
51            cache_max_age: 3600, // 1 hour default
52            auth: None,
53        }
54    }
55
56    /// Create a new application state with custom cache max-age.
57    pub fn with_cache_max_age(tile_service: TileService<S>, cache_max_age: u32) -> Self {
58        Self {
59            tile_service: Arc::new(tile_service),
60            cache_max_age,
61            auth: None,
62        }
63    }
64
65    /// Set authentication for the viewer to generate signed tile URLs.
66    pub fn with_auth(mut self, auth: SignedUrlAuth) -> Self {
67        self.auth = Some(auth);
68        self
69    }
70}
71
72impl<S: SlideSource> Clone for AppState<S> {
73    fn clone(&self) -> Self {
74        Self {
75            tile_service: Arc::clone(&self.tile_service),
76            cache_max_age: self.cache_max_age,
77            auth: self.auth.clone(),
78        }
79    }
80}
81
82// =============================================================================
83// Request Parameters
84// =============================================================================
85
86/// Path parameters for tile requests.
87///
88/// Extracted from: `/tiles/{slide_id}/{level}/{x}/{filename}`
89/// where filename is `{y}` or `{y}.jpg`
90#[derive(Debug, Deserialize)]
91pub struct TilePathParams {
92    /// Slide identifier (can be a path like "bucket/folder/slide.svs")
93    pub slide_id: String,
94
95    /// Pyramid level (0 = highest resolution)
96    pub level: usize,
97
98    /// Tile X coordinate (0-indexed from left)
99    pub x: u32,
100
101    /// Tile Y coordinate with optional .jpg extension (e.g., "0" or "0.jpg")
102    pub filename: String,
103}
104
105impl TilePathParams {
106    /// Parse the Y coordinate from the filename, stripping any .jpg extension.
107    pub fn y(&self) -> Result<u32, std::num::ParseIntError> {
108        let y_str = self.filename.strip_suffix(".jpg").unwrap_or(&self.filename);
109        y_str.parse()
110    }
111}
112
113/// Query parameters for tile requests.
114#[derive(Debug, Deserialize)]
115pub struct TileQueryParams {
116    /// JPEG quality (1-100, defaults to 80)
117    #[serde(default = "default_quality")]
118    pub quality: u8,
119
120    /// Signature for authentication (handled by auth middleware)
121    #[serde(default)]
122    pub sig: Option<String>,
123
124    /// Expiry timestamp for authentication (handled by auth middleware)
125    #[serde(default)]
126    pub exp: Option<u64>,
127}
128
129fn default_quality() -> u8 {
130    DEFAULT_JPEG_QUALITY
131}
132
133/// Query parameters for the slides list endpoint.
134#[derive(Debug, Deserialize)]
135pub struct SlidesQueryParams {
136    /// Maximum number of slides to return (default: 100, max: 1000)
137    #[serde(default = "default_limit")]
138    pub limit: u32,
139
140    /// Continuation token for pagination (from previous response)
141    #[serde(default)]
142    pub cursor: Option<String>,
143
144    /// Filter by path prefix (e.g., "folder/subfolder/")
145    #[serde(default)]
146    pub prefix: Option<String>,
147
148    /// Search string to filter slide names (case-insensitive substring match)
149    #[serde(default)]
150    pub search: Option<String>,
151
152    /// Signature for authentication (handled by auth middleware)
153    #[serde(default)]
154    pub sig: Option<String>,
155
156    /// Expiry timestamp for authentication (handled by auth middleware)
157    #[serde(default)]
158    pub exp: Option<u64>,
159}
160
161fn default_limit() -> u32 {
162    100
163}
164
165/// Query parameters for thumbnail requests.
166#[derive(Debug, Deserialize)]
167pub struct ThumbnailQueryParams {
168    /// Maximum width or height for the thumbnail (default: 512, max: 2048)
169    #[serde(default = "default_thumbnail_size")]
170    pub max_size: u32,
171
172    /// JPEG quality (1-100, defaults to 80)
173    #[serde(default = "default_quality")]
174    pub quality: u8,
175
176    /// Signature for authentication (handled by auth middleware)
177    #[serde(default)]
178    pub sig: Option<String>,
179
180    /// Expiry timestamp for authentication (handled by auth middleware)
181    #[serde(default)]
182    pub exp: Option<u64>,
183}
184
185fn default_thumbnail_size() -> u32 {
186    512
187}
188
189// =============================================================================
190// Response Types
191// =============================================================================
192
193/// JSON error response returned for all error conditions.
194#[derive(Debug, Serialize)]
195pub struct ErrorResponse {
196    /// Error type identifier (e.g., "not_found", "invalid_request")
197    pub error: String,
198
199    /// Human-readable error message
200    pub message: String,
201
202    /// HTTP status code (included for convenience)
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub status: Option<u16>,
205}
206
207impl ErrorResponse {
208    /// Create a new error response.
209    pub fn new(error: impl Into<String>, message: impl Into<String>) -> Self {
210        Self {
211            error: error.into(),
212            message: message.into(),
213            status: None,
214        }
215    }
216
217    /// Create a new error response with status code.
218    pub fn with_status(
219        error: impl Into<String>,
220        message: impl Into<String>,
221        status: StatusCode,
222    ) -> Self {
223        Self {
224            error: error.into(),
225            message: message.into(),
226            status: Some(status.as_u16()),
227        }
228    }
229}
230
231/// Health check response.
232#[derive(Debug, Serialize)]
233pub struct HealthResponse {
234    /// Service status
235    pub status: String,
236
237    /// Service version
238    pub version: String,
239}
240
241/// Response from the slides list endpoint.
242#[derive(Debug, Serialize)]
243pub struct SlidesResponse {
244    /// List of slide paths/IDs
245    pub slides: Vec<String>,
246
247    /// Continuation token for next page (None if no more pages)
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub next_cursor: Option<String>,
250}
251
252/// Metadata for a single pyramid level.
253#[derive(Debug, Serialize)]
254pub struct LevelMetadataResponse {
255    /// Pyramid level index (0 = highest resolution)
256    pub level: usize,
257
258    /// Width of this level in pixels
259    pub width: u32,
260
261    /// Height of this level in pixels
262    pub height: u32,
263
264    /// Width of each tile in pixels
265    pub tile_width: u32,
266
267    /// Height of each tile in pixels
268    pub tile_height: u32,
269
270    /// Number of tiles in X direction
271    pub tiles_x: u32,
272
273    /// Number of tiles in Y direction
274    pub tiles_y: u32,
275
276    /// Downsample factor relative to level 0
277    pub downsample: f64,
278}
279
280/// Response from the slide metadata endpoint.
281#[derive(Debug, Serialize)]
282pub struct SlideMetadataResponse {
283    /// Slide identifier
284    pub slide_id: String,
285
286    /// Detected slide format (e.g., "aperio_svs", "generic_tiff")
287    pub format: String,
288
289    /// Width of the full-resolution image in pixels
290    pub width: u32,
291
292    /// Height of the full-resolution image in pixels
293    pub height: u32,
294
295    /// Number of pyramid levels
296    pub level_count: usize,
297
298    /// Metadata for each pyramid level
299    pub levels: Vec<LevelMetadataResponse>,
300}
301
302// =============================================================================
303// Error Mapping
304// =============================================================================
305
306/// Convert TileError to HTTP response.
307///
308/// This implementation logs errors appropriately based on their severity:
309/// - 4xx errors are logged at WARN level (client errors)
310/// - 5xx errors are logged at ERROR level (server errors)
311impl IntoResponse for TileError {
312    fn into_response(self) -> Response {
313        let (status, error_type, message) = match &self {
314            // 404 Not Found
315            TileError::SlideNotFound { slide_id } => (
316                StatusCode::NOT_FOUND,
317                "not_found",
318                format!("Slide not found: {}", slide_id),
319            ),
320
321            // 400 Bad Request - Invalid parameters
322            TileError::InvalidLevel { level, max_levels } => (
323                StatusCode::BAD_REQUEST,
324                "invalid_level",
325                format!(
326                    "Invalid level: {} (slide has {} levels, valid range: 0-{})",
327                    level,
328                    max_levels,
329                    max_levels.saturating_sub(1)
330                ),
331            ),
332
333            TileError::TileOutOfBounds {
334                level,
335                x,
336                y,
337                max_x,
338                max_y,
339            } => (
340                StatusCode::BAD_REQUEST,
341                "tile_out_of_bounds",
342                format!(
343                    "Tile coordinates ({}, {}) at level {} are out of bounds (max: {}, {})",
344                    x,
345                    y,
346                    level,
347                    max_x.saturating_sub(1),
348                    max_y.saturating_sub(1)
349                ),
350            ),
351
352            TileError::InvalidQuality { quality } => (
353                StatusCode::BAD_REQUEST,
354                "invalid_quality",
355                format!("Invalid quality: {} (must be 1-100)", quality),
356            ),
357
358            // TIFF structure errors map to 415 Unsupported Media Type
359            TileError::Slide(TiffError::Io(io_err)) => match io_err {
360                IoError::NotFound(path) => (
361                    StatusCode::NOT_FOUND,
362                    "not_found",
363                    format!("Resource not found: {}", path),
364                ),
365                _ => (
366                    StatusCode::INTERNAL_SERVER_ERROR,
367                    "io_error",
368                    format!("I/O error: {}", io_err),
369                ),
370            },
371            TileError::Slide(tiff_err) => (
372                StatusCode::UNSUPPORTED_MEDIA_TYPE,
373                "unsupported_format",
374                tiff_err.to_string(),
375            ),
376
377            // 500 Internal Server Error - I/O and processing errors
378            TileError::Io(io_err) => {
379                // Map specific I/O errors
380                match io_err {
381                    IoError::NotFound(path) => (
382                        StatusCode::NOT_FOUND,
383                        "not_found",
384                        format!("Resource not found: {}", path),
385                    ),
386                    _ => (
387                        StatusCode::INTERNAL_SERVER_ERROR,
388                        "io_error",
389                        format!("I/O error: {}", io_err),
390                    ),
391                }
392            }
393
394            TileError::DecodeError { message } => (
395                StatusCode::INTERNAL_SERVER_ERROR,
396                "decode_error",
397                format!("Failed to decode tile: {}", message),
398            ),
399
400            TileError::EncodeError { message } => (
401                StatusCode::INTERNAL_SERVER_ERROR,
402                "encode_error",
403                format!("Failed to encode tile: {}", message),
404            ),
405        };
406
407        // Log errors based on severity
408        if status.is_server_error() {
409            error!(
410                error_type = error_type,
411                status = status.as_u16(),
412                "Server error: {}",
413                message
414            );
415        } else if status.is_client_error() {
416            // Log 404s at debug level (common and expected), others at warn
417            if status == StatusCode::NOT_FOUND {
418                debug!(
419                    error_type = error_type,
420                    status = status.as_u16(),
421                    "Resource not found: {}",
422                    message
423                );
424            } else {
425                warn!(
426                    error_type = error_type,
427                    status = status.as_u16(),
428                    "Client error: {}",
429                    message
430                );
431            }
432        }
433
434        let error_response = ErrorResponse::with_status(error_type, message, status);
435
436        (status, Json(error_response)).into_response()
437    }
438}
439
440/// Convert FormatError to HTTP response.
441///
442/// FormatError typically indicates an unsupported file format (HTTP 415)
443/// or an I/O error during format detection.
444impl IntoResponse for FormatError {
445    fn into_response(self) -> Response {
446        let (status, error_type, message) = match &self {
447            FormatError::Io(io_err) => match io_err {
448                IoError::NotFound(path) => (
449                    StatusCode::NOT_FOUND,
450                    "not_found",
451                    format!("Slide not found: {}", path),
452                ),
453                IoError::S3(msg) => (
454                    StatusCode::INTERNAL_SERVER_ERROR,
455                    "storage_error",
456                    format!("Storage error: {}", msg),
457                ),
458                IoError::Connection(msg) => (
459                    StatusCode::BAD_GATEWAY,
460                    "connection_error",
461                    format!("Connection error: {}", msg),
462                ),
463                IoError::RangeOutOfBounds { .. } => (
464                    StatusCode::INTERNAL_SERVER_ERROR,
465                    "io_error",
466                    format!("I/O error: {}", io_err),
467                ),
468            },
469
470            FormatError::Tiff(tiff_err) => match tiff_err {
471                TiffError::Io(io_err) => match io_err {
472                    IoError::NotFound(path) => (
473                        StatusCode::NOT_FOUND,
474                        "not_found",
475                        format!("Slide not found: {}", path),
476                    ),
477                    IoError::S3(msg) => (
478                        StatusCode::INTERNAL_SERVER_ERROR,
479                        "storage_error",
480                        format!("Storage error: {}", msg),
481                    ),
482                    IoError::Connection(msg) => (
483                        StatusCode::BAD_GATEWAY,
484                        "connection_error",
485                        format!("Connection error: {}", msg),
486                    ),
487                    IoError::RangeOutOfBounds { .. } => (
488                        StatusCode::INTERNAL_SERVER_ERROR,
489                        "io_error",
490                        format!("I/O error: {}", io_err),
491                    ),
492                },
493                _ => (
494                    StatusCode::UNSUPPORTED_MEDIA_TYPE,
495                    "unsupported_format",
496                    tiff_err.to_string(),
497                ),
498            },
499
500            FormatError::UnsupportedFormat { reason } => (
501                StatusCode::UNSUPPORTED_MEDIA_TYPE,
502                "unsupported_format",
503                format!("Unsupported format: {}", reason),
504            ),
505        };
506
507        // Log errors based on severity
508        if status.is_server_error() {
509            error!(
510                error_type = error_type,
511                status = status.as_u16(),
512                "Server error: {}",
513                message
514            );
515        } else if status == StatusCode::UNSUPPORTED_MEDIA_TYPE {
516            warn!(
517                error_type = error_type,
518                status = status.as_u16(),
519                "Unsupported format: {}",
520                message
521            );
522        } else if status == StatusCode::NOT_FOUND {
523            debug!(
524                error_type = error_type,
525                status = status.as_u16(),
526                "Resource not found: {}",
527                message
528            );
529        }
530
531        let error_response = ErrorResponse::with_status(error_type, message, status);
532
533        (status, Json(error_response)).into_response()
534    }
535}
536
537/// Wrapper for handler errors to implement IntoResponse.
538pub struct HandlerError(pub TileError);
539
540impl IntoResponse for HandlerError {
541    fn into_response(self) -> Response {
542        self.0.into_response()
543    }
544}
545
546impl From<TileError> for HandlerError {
547    fn from(err: TileError) -> Self {
548        HandlerError(err)
549    }
550}
551
552/// Wrapper for slides listing errors to implement IntoResponse.
553pub struct SlidesError(pub IoError);
554
555impl IntoResponse for SlidesError {
556    fn into_response(self) -> Response {
557        let (status, error_type, message) = match &self.0 {
558            IoError::NotFound(path) => (
559                StatusCode::NOT_FOUND,
560                "not_found",
561                format!("Resource not found: {}", path),
562            ),
563            IoError::S3(msg) => (
564                StatusCode::INTERNAL_SERVER_ERROR,
565                "storage_error",
566                format!("Storage error: {}", msg),
567            ),
568            IoError::Connection(msg) => (
569                StatusCode::BAD_GATEWAY,
570                "connection_error",
571                format!("Connection error: {}", msg),
572            ),
573            IoError::RangeOutOfBounds { .. } => (
574                StatusCode::INTERNAL_SERVER_ERROR,
575                "io_error",
576                format!("I/O error: {}", self.0),
577            ),
578        };
579
580        // Log based on severity
581        if status.is_server_error() {
582            error!(
583                error_type = error_type,
584                status = status.as_u16(),
585                "Server error: {}",
586                message
587            );
588        } else {
589            debug!(
590                error_type = error_type,
591                status = status.as_u16(),
592                "Client error: {}",
593                message
594            );
595        }
596
597        let error_response = ErrorResponse::with_status(error_type, message, status);
598        (status, Json(error_response)).into_response()
599    }
600}
601
602impl From<IoError> for SlidesError {
603    fn from(err: IoError) -> Self {
604        SlidesError(err)
605    }
606}
607
608/// Wrapper for slide metadata errors to implement IntoResponse.
609pub struct SlideMetadataError(pub FormatError);
610
611impl IntoResponse for SlideMetadataError {
612    fn into_response(self) -> Response {
613        self.0.into_response()
614    }
615}
616
617impl From<FormatError> for SlideMetadataError {
618    fn from(err: FormatError) -> Self {
619        SlideMetadataError(err)
620    }
621}
622
623// =============================================================================
624// Handlers
625// =============================================================================
626
627/// Handle tile requests.
628///
629/// # Endpoint
630///
631/// `GET /tiles/{slide_id}/{level}/{x}/{y}.jpg`
632///
633/// # Path Parameters
634///
635/// - `slide_id`: Slide identifier (URL-encoded if contains special characters)
636/// - `level`: Pyramid level (0 = highest resolution)
637/// - `x`: Tile X coordinate
638/// - `y`: Tile Y coordinate
639///
640/// # Query Parameters
641///
642/// - `quality`: JPEG quality 1-100 (default: 80)
643/// - `sig`: Authentication signature (optional, for signed URLs)
644/// - `exp`: Signature expiry timestamp (optional, for signed URLs)
645///
646/// # Response
647///
648/// - `200 OK`: JPEG tile image with `Content-Type: image/jpeg`
649/// - `400 Bad Request`: Invalid level or tile coordinates
650/// - `404 Not Found`: Slide not found
651/// - `415 Unsupported Media Type`: Slide format not supported
652/// - `500 Internal Server Error`: Processing error
653///
654/// # Headers
655///
656/// - `Content-Type: image/jpeg`
657/// - `Cache-Control: public, max-age={cache_max_age}`
658/// - `X-Tile-Cache-Hit: true|false`
659pub async fn tile_handler<S: SlideSource>(
660    State(state): State<AppState<S>>,
661    Path(params): Path<TilePathParams>,
662    Query(query): Query<TileQueryParams>,
663) -> Result<Response, HandlerError> {
664    // Parse Y coordinate from filename (handles both "0" and "0.jpg")
665    let y = params.y().map_err(|_| {
666        HandlerError(TileError::TileOutOfBounds {
667            level: params.level,
668            x: params.x,
669            y: 0,
670            max_x: 0,
671            max_y: 0,
672        })
673    })?;
674
675    // Build tile request
676    let request =
677        TileRequest::with_quality(&params.slide_id, params.level, params.x, y, query.quality);
678
679    // Get tile from service
680    let response = state.tile_service.get_tile(request).await?;
681
682    // Build HTTP response with appropriate headers
683    let http_response = Response::builder()
684        .status(StatusCode::OK)
685        .header(header::CONTENT_TYPE, "image/jpeg")
686        .header(
687            header::CACHE_CONTROL,
688            format!("public, max-age={}", state.cache_max_age),
689        )
690        .header("X-Tile-Cache-Hit", response.cache_hit.to_string())
691        .header("X-Tile-Quality", response.quality.to_string())
692        .body(axum::body::Body::from(response.data))
693        .unwrap();
694
695    Ok(http_response)
696}
697
698/// Handle health check requests.
699///
700/// # Endpoint
701///
702/// `GET /health`
703///
704/// # Response
705///
706/// `200 OK` with JSON body:
707/// ```json
708/// {
709///   "status": "healthy",
710///   "version": "0.1.0"
711/// }
712/// ```
713pub async fn health_handler() -> Json<HealthResponse> {
714    Json(HealthResponse {
715        status: "healthy".to_string(),
716        version: env!("CARGO_PKG_VERSION").to_string(),
717    })
718}
719
720/// Handle slides list requests.
721///
722/// # Endpoint
723///
724/// `GET /slides`
725///
726/// # Query Parameters
727///
728/// - `limit`: Maximum number of slides to return (default: 100, max: 1000)
729/// - `cursor`: Continuation token for pagination (from previous response)
730/// - `sig`: Authentication signature (for signed URLs)
731/// - `exp`: Signature expiry timestamp (for signed URLs)
732///
733/// # Response
734///
735/// `200 OK` with JSON body:
736/// ```json
737/// {
738///   "slides": ["path/to/slide1.svs", "path/to/slide2.tif"],
739///   "next_cursor": "continuation_token_or_null"
740/// }
741/// ```
742///
743/// # Errors
744///
745/// - `401 Unauthorized`: Invalid or missing signature
746/// - `500 Internal Server Error`: Storage error
747pub async fn slides_handler<S: SlideSource>(
748    State(state): State<AppState<S>>,
749    Query(query): Query<SlidesQueryParams>,
750) -> Result<Json<SlidesResponse>, SlidesError> {
751    // Clamp limit to valid range (1-1000)
752    let limit = query.limit.clamp(1, 1000);
753
754    // List slides from the source with optional prefix filter
755    let result = state
756        .tile_service
757        .registry()
758        .source()
759        .list_slides(limit, query.cursor.as_deref(), query.prefix.as_deref())
760        .await?;
761
762    // Apply search filter if provided (case-insensitive substring match)
763    let slides = if let Some(ref search) = query.search {
764        let search_lower = search.to_lowercase();
765        result
766            .slides
767            .into_iter()
768            .filter(|s| s.to_lowercase().contains(&search_lower))
769            .collect()
770    } else {
771        result.slides
772    };
773
774    Ok(Json(SlidesResponse {
775        slides,
776        next_cursor: result.next_cursor,
777    }))
778}
779
780/// Handle slide metadata requests.
781///
782/// # Endpoint
783///
784/// `GET /slides/{slide_id}`
785///
786/// # Path Parameters
787///
788/// - `slide_id`: Slide identifier (URL-encoded if contains special characters)
789///
790/// # Response
791///
792/// `200 OK` with JSON body containing slide metadata:
793/// ```json
794/// {
795///   "slide_id": "path/to/slide.svs",
796///   "format": "aperio_svs",
797///   "width": 46920,
798///   "height": 33600,
799///   "level_count": 4,
800///   "levels": [
801///     {
802///       "level": 0,
803///       "width": 46920,
804///       "height": 33600,
805///       "tile_width": 256,
806///       "tile_height": 256,
807///       "tiles_x": 184,
808///       "tiles_y": 132,
809///       "downsample": 1.0
810///     }
811///   ]
812/// }
813/// ```
814///
815/// # Errors
816///
817/// - `401 Unauthorized`: Invalid or missing signature (when auth enabled)
818/// - `404 Not Found`: Slide not found
819/// - `415 Unsupported Media Type`: Slide format not supported
820/// - `500 Internal Server Error`: Storage or processing error
821pub async fn slide_metadata_handler<S: SlideSource>(
822    State(state): State<AppState<S>>,
823    Path(slide_id): Path<String>,
824) -> Result<Json<SlideMetadataResponse>, SlideMetadataError> {
825    // Get slide from registry (opens and caches if needed)
826    let slide = state.tile_service.registry().get_slide(&slide_id).await?;
827
828    // Get dimensions (should always be available for valid slides)
829    let (width, height) = slide.dimensions().unwrap_or((0, 0));
830
831    // Build level metadata for each pyramid level
832    let level_count = slide.level_count();
833    let levels: Vec<LevelMetadataResponse> = (0..level_count)
834        .filter_map(|level| {
835            slide.level_info(level).map(|info| LevelMetadataResponse {
836                level,
837                width: info.width,
838                height: info.height,
839                tile_width: info.tile_width,
840                tile_height: info.tile_height,
841                tiles_x: info.tiles_x,
842                tiles_y: info.tiles_y,
843                downsample: info.downsample,
844            })
845        })
846        .collect();
847
848    Ok(Json(SlideMetadataResponse {
849        slide_id,
850        format: slide.format().name().to_string(),
851        width,
852        height,
853        level_count,
854        levels,
855    }))
856}
857
858/// Handle viewer requests - serves an HTML page with OpenSeadragon viewer.
859///
860/// # Endpoint
861///
862/// `GET /view/{slide_id}`
863///
864/// # Path Parameters
865///
866/// - `slide_id`: Slide identifier (URL-encoded if contains special characters)
867///
868/// # Response
869///
870/// `200 OK` with HTML page containing an embedded OpenSeadragon viewer.
871///
872/// # Errors
873///
874/// - `404 Not Found`: Slide not found
875/// - `415 Unsupported Media Type`: Slide format not supported
876/// - `500 Internal Server Error`: Storage or processing error
877pub async fn viewer_handler<S: SlideSource>(
878    State(state): State<AppState<S>>,
879    Path(slide_id): Path<String>,
880    headers: HeaderMap,
881) -> Result<Html<String>, SlideMetadataError> {
882    // Get slide from registry to retrieve metadata
883    let slide = state.tile_service.registry().get_slide(&slide_id).await?;
884
885    // Get dimensions
886    let (width, height) = slide.dimensions().unwrap_or((0, 0));
887
888    // Build level metadata
889    let level_count = slide.level_count();
890    let levels: Vec<LevelMetadataResponse> = (0..level_count)
891        .filter_map(|level| {
892            slide.level_info(level).map(|info| LevelMetadataResponse {
893                level,
894                width: info.width,
895                height: info.height,
896                tile_width: info.tile_width,
897                tile_height: info.tile_height,
898                tiles_x: info.tiles_x,
899                tiles_y: info.tiles_y,
900                downsample: info.downsample,
901            })
902        })
903        .collect();
904
905    let metadata = SlideMetadataResponse {
906        slide_id: slide_id.clone(),
907        format: slide.format().name().to_string(),
908        width,
909        height,
910        level_count,
911        levels,
912    };
913
914    // Extract host from headers, defaulting to localhost:3000
915    let host = headers
916        .get(header::HOST)
917        .and_then(|h| h.to_str().ok())
918        .unwrap_or("localhost:3000");
919
920    // Detect protocol from X-Forwarded-Proto header (for reverse proxy support)
921    // or default to http for local development
922    let proto = headers
923        .get("x-forwarded-proto")
924        .and_then(|h| h.to_str().ok())
925        .unwrap_or("http");
926
927    // Generate the base URL from the host and protocol
928    let base_url = format!("{}://{}", proto, host);
929
930    // Generate viewer token if auth is enabled
931    // This token authorizes access to all tiles for this specific slide
932    let auth_query = state
933        .auth
934        .as_ref()
935        .map(|auth| {
936            // Generate viewer token valid for 1 hour
937            let ttl = Duration::from_secs(3600);
938            let (token, expiry) = auth.generate_viewer_token(&slide_id, ttl);
939            format!("?vt={}&exp={}", token, expiry)
940        })
941        .unwrap_or_default();
942
943    // Generate the viewer HTML with auth info
944    let html = super::viewer::generate_viewer_html(&slide_id, &metadata, &base_url, &auth_query);
945
946    Ok(Html(html))
947}
948
949/// Handle DZI descriptor requests - returns XML descriptor for Deep Zoom viewers.
950///
951/// # Endpoint
952///
953/// `GET /slides/{slide_id}/dzi`
954///
955/// # Path Parameters
956///
957/// - `slide_id`: Slide identifier
958///
959/// # Response
960///
961/// `200 OK` with XML body containing DZI descriptor.
962///
963/// # Example Response
964///
965/// ```xml
966/// <?xml version="1.0" encoding="UTF-8"?>
967/// <Image xmlns="http://schemas.microsoft.com/deepzoom/2008"
968///        TileSize="256"
969///        Overlap="0"
970///        Format="jpg">
971///   <Size Width="46920" Height="33600" />
972/// </Image>
973/// ```
974///
975/// # Errors
976///
977/// - `404 Not Found`: Slide not found
978/// - `415 Unsupported Media Type`: Slide format not supported
979/// - `500 Internal Server Error`: Storage or processing error
980pub async fn dzi_descriptor_handler<S: SlideSource>(
981    State(state): State<AppState<S>>,
982    Path(slide_id): Path<String>,
983) -> Result<Response, SlideMetadataError> {
984    // Get slide from registry
985    let slide = state.tile_service.registry().get_slide(&slide_id).await?;
986
987    // Get dimensions
988    let (width, height) = slide.dimensions().unwrap_or((0, 0));
989
990    // Get tile size from level 0 (or default)
991    let tile_size = slide.tile_size(0).map(|(w, _)| w).unwrap_or(256);
992
993    // Generate DZI XML
994    let xml = super::dzi::generate_dzi_xml(width, height, tile_size);
995
996    // Build response with XML content type
997    let response = Response::builder()
998        .status(StatusCode::OK)
999        .header(header::CONTENT_TYPE, "application/xml")
1000        .header(
1001            header::CACHE_CONTROL,
1002            format!("public, max-age={}", state.cache_max_age),
1003        )
1004        .body(axum::body::Body::from(xml))
1005        .unwrap();
1006
1007    Ok(response)
1008}
1009
1010/// Handle thumbnail requests - returns a low-resolution preview image.
1011///
1012/// # Endpoint
1013///
1014/// `GET /slides/{slide_id}/thumbnail`
1015///
1016/// # Path Parameters
1017///
1018/// - `slide_id`: Slide identifier (URL-encoded if contains special characters)
1019///
1020/// # Query Parameters
1021///
1022/// - `max_size`: Maximum width or height for the thumbnail (default: 512, max: 2048)
1023/// - `quality`: JPEG quality 1-100 (default: 80)
1024/// - `sig`: Authentication signature (for signed URLs)
1025/// - `exp`: Signature expiry timestamp (for signed URLs)
1026///
1027/// # Response
1028///
1029/// `200 OK` with JPEG thumbnail image.
1030///
1031/// # Errors
1032///
1033/// - `400 Bad Request`: Invalid quality or max_size parameter
1034/// - `404 Not Found`: Slide not found
1035/// - `415 Unsupported Media Type`: Slide format not supported
1036/// - `500 Internal Server Error`: Storage or processing error
1037pub async fn thumbnail_handler<S: SlideSource>(
1038    State(state): State<AppState<S>>,
1039    Path(slide_id): Path<String>,
1040    Query(query): Query<ThumbnailQueryParams>,
1041) -> Result<Response, HandlerError> {
1042    // Clamp max_size to reasonable bounds (64 to 2048)
1043    let requested_size = query.max_size;
1044    let max_size = requested_size.clamp(64, 2048);
1045    let was_clamped = max_size != requested_size;
1046
1047    // Generate thumbnail
1048    let response = state
1049        .tile_service
1050        .generate_thumbnail(&slide_id, max_size, query.quality)
1051        .await?;
1052
1053    // Build HTTP response with appropriate headers
1054    let mut builder = Response::builder()
1055        .status(StatusCode::OK)
1056        .header(header::CONTENT_TYPE, "image/jpeg")
1057        .header(
1058            header::CACHE_CONTROL,
1059            format!("public, max-age={}", state.cache_max_age),
1060        )
1061        .header("X-Tile-Cache-Hit", response.cache_hit.to_string())
1062        .header("X-Tile-Quality", response.quality.to_string());
1063
1064    // Add header indicating if max_size was clamped
1065    if was_clamped {
1066        builder = builder
1067            .header("X-Thumbnail-Size-Clamped", "true")
1068            .header("X-Thumbnail-Requested-Size", requested_size.to_string())
1069            .header("X-Thumbnail-Actual-Size", max_size.to_string());
1070    }
1071
1072    let http_response = builder.body(axum::body::Body::from(response.data)).unwrap();
1073
1074    Ok(http_response)
1075}
1076
1077// =============================================================================
1078// Tests
1079// =============================================================================
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084    use axum::http::StatusCode;
1085
1086    #[test]
1087    fn test_error_response_serialization() {
1088        let response = ErrorResponse::new("test_error", "Test message");
1089        let json = serde_json::to_string(&response).unwrap();
1090        assert!(json.contains("test_error"));
1091        assert!(json.contains("Test message"));
1092        assert!(!json.contains("status")); // status is None, should be skipped
1093    }
1094
1095    #[test]
1096    fn test_error_response_with_status() {
1097        let response =
1098            ErrorResponse::with_status("not_found", "Slide not found", StatusCode::NOT_FOUND);
1099        let json = serde_json::to_string(&response).unwrap();
1100        assert!(json.contains("404"));
1101    }
1102
1103    #[test]
1104    fn test_tile_error_to_status_code() {
1105        // Test SlideNotFound -> 404
1106        let err = TileError::SlideNotFound {
1107            slide_id: "test.svs".to_string(),
1108        };
1109        let response = err.into_response();
1110        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1111
1112        // Test InvalidLevel -> 400
1113        let err = TileError::InvalidLevel {
1114            level: 5,
1115            max_levels: 3,
1116        };
1117        let response = err.into_response();
1118        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1119
1120        // Test TileOutOfBounds -> 400
1121        let err = TileError::TileOutOfBounds {
1122            level: 0,
1123            x: 100,
1124            y: 100,
1125            max_x: 10,
1126            max_y: 10,
1127        };
1128        let response = err.into_response();
1129        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1130
1131        // Test UnsupportedCompression -> 415
1132        let err = TileError::Slide(TiffError::UnsupportedCompression("LZW".to_string()));
1133        let response = err.into_response();
1134        assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1135
1136        // Test StripOrganization -> 415
1137        let err = TileError::Slide(TiffError::StripOrganization);
1138        let response = err.into_response();
1139        assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1140
1141        // Test DecodeError -> 500
1142        let err = TileError::DecodeError {
1143            message: "test".to_string(),
1144        };
1145        let response = err.into_response();
1146        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1147    }
1148
1149    #[test]
1150    fn test_health_response_serialization() {
1151        let response = HealthResponse {
1152            status: "healthy".to_string(),
1153            version: "0.1.0".to_string(),
1154        };
1155        let json = serde_json::to_string(&response).unwrap();
1156        assert!(json.contains("healthy"));
1157        assert!(json.contains("0.1.0"));
1158    }
1159
1160    #[test]
1161    fn test_tile_query_params_defaults() {
1162        // Test that default quality is applied
1163        let params: TileQueryParams = serde_json::from_str("{}").unwrap();
1164        assert_eq!(params.quality, DEFAULT_JPEG_QUALITY);
1165        assert!(params.sig.is_none());
1166        assert!(params.exp.is_none());
1167    }
1168
1169    #[test]
1170    fn test_tile_query_params_with_values() {
1171        let params: TileQueryParams =
1172            serde_json::from_str(r#"{"quality": 95, "sig": "abc123", "exp": 1234567890}"#).unwrap();
1173        assert_eq!(params.quality, 95);
1174        assert_eq!(params.sig, Some("abc123".to_string()));
1175        assert_eq!(params.exp, Some(1234567890));
1176    }
1177
1178    #[test]
1179    fn test_format_error_to_status_code() {
1180        // Test IoError::NotFound -> 404
1181        let err = FormatError::Io(IoError::NotFound("test.svs".to_string()));
1182        let response = err.into_response();
1183        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1184
1185        // Test IoError::S3 -> 500
1186        let err = FormatError::Io(IoError::S3("connection refused".to_string()));
1187        let response = err.into_response();
1188        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1189
1190        // Test IoError::Connection -> 502
1191        let err = FormatError::Io(IoError::Connection("timeout".to_string()));
1192        let response = err.into_response();
1193        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
1194
1195        // Test UnsupportedCompression -> 415
1196        let err = FormatError::Tiff(TiffError::UnsupportedCompression("LZW".to_string()));
1197        let response = err.into_response();
1198        assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1199
1200        // Test StripOrganization -> 415
1201        let err = FormatError::Tiff(TiffError::StripOrganization);
1202        let response = err.into_response();
1203        assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1204
1205        // Test InvalidMagic -> 415
1206        let err = FormatError::Tiff(TiffError::InvalidMagic(0x1234));
1207        let response = err.into_response();
1208        assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1209
1210        // Test InvalidVersion -> 415
1211        let err = FormatError::Tiff(TiffError::InvalidVersion(99));
1212        let response = err.into_response();
1213        assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1214
1215        // Test UnsupportedFormat -> 415
1216        let err = FormatError::UnsupportedFormat {
1217            reason: "not a TIFF file".to_string(),
1218        };
1219        let response = err.into_response();
1220        assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1221
1222        // Test other TiffError -> 415
1223        let err = FormatError::Tiff(TiffError::MissingTag("TileOffsets"));
1224        let response = err.into_response();
1225        assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1226    }
1227
1228    #[test]
1229    fn test_io_error_in_tile_error() {
1230        // Test NotFound via I/O -> 404
1231        let err = TileError::Io(IoError::NotFound("s3://bucket/slide.svs".to_string()));
1232        let response = err.into_response();
1233        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1234
1235        // Test S3 error -> 500
1236        let err = TileError::Io(IoError::S3("access denied".to_string()));
1237        let response = err.into_response();
1238        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1239
1240        // Test Connection error -> 500
1241        let err = TileError::Io(IoError::Connection("reset by peer".to_string()));
1242        let response = err.into_response();
1243        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1244    }
1245
1246    #[test]
1247    fn test_slides_query_params_defaults() {
1248        let params: SlidesQueryParams = serde_json::from_str("{}").unwrap();
1249        assert_eq!(params.limit, 100);
1250        assert!(params.cursor.is_none());
1251        assert!(params.sig.is_none());
1252        assert!(params.exp.is_none());
1253    }
1254
1255    #[test]
1256    fn test_slides_query_params_with_values() {
1257        let params: SlidesQueryParams = serde_json::from_str(
1258            r#"{"limit": 50, "cursor": "token123", "sig": "abc", "exp": 1234567890}"#,
1259        )
1260        .unwrap();
1261        assert_eq!(params.limit, 50);
1262        assert_eq!(params.cursor, Some("token123".to_string()));
1263        assert_eq!(params.sig, Some("abc".to_string()));
1264        assert_eq!(params.exp, Some(1234567890));
1265    }
1266
1267    #[test]
1268    fn test_slides_response_serialization() {
1269        let response = SlidesResponse {
1270            slides: vec!["slide1.svs".to_string(), "folder/slide2.tif".to_string()],
1271            next_cursor: Some("token123".to_string()),
1272        };
1273        let json = serde_json::to_string(&response).unwrap();
1274        assert!(json.contains("slide1.svs"));
1275        assert!(json.contains("folder/slide2.tif"));
1276        assert!(json.contains("token123"));
1277    }
1278
1279    #[test]
1280    fn test_slides_response_no_cursor() {
1281        let response = SlidesResponse {
1282            slides: vec!["slide.svs".to_string()],
1283            next_cursor: None,
1284        };
1285        let json = serde_json::to_string(&response).unwrap();
1286        assert!(!json.contains("next_cursor"));
1287    }
1288
1289    #[test]
1290    fn test_slides_error_to_status_code() {
1291        // Test NotFound -> 404
1292        let err = SlidesError(IoError::NotFound("bucket/slide.svs".to_string()));
1293        let response = err.into_response();
1294        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1295
1296        // Test S3 -> 500
1297        let err = SlidesError(IoError::S3("access denied".to_string()));
1298        let response = err.into_response();
1299        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1300
1301        // Test Connection -> 502
1302        let err = SlidesError(IoError::Connection("timeout".to_string()));
1303        let response = err.into_response();
1304        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
1305    }
1306
1307    #[test]
1308    fn test_level_metadata_response_serialization() {
1309        let response = LevelMetadataResponse {
1310            level: 0,
1311            width: 46920,
1312            height: 33600,
1313            tile_width: 256,
1314            tile_height: 256,
1315            tiles_x: 184,
1316            tiles_y: 132,
1317            downsample: 1.0,
1318        };
1319        let json = serde_json::to_string(&response).unwrap();
1320        assert!(json.contains("\"level\":0"));
1321        assert!(json.contains("\"width\":46920"));
1322        assert!(json.contains("\"height\":33600"));
1323        assert!(json.contains("\"tile_width\":256"));
1324        assert!(json.contains("\"tile_height\":256"));
1325        assert!(json.contains("\"tiles_x\":184"));
1326        assert!(json.contains("\"tiles_y\":132"));
1327        assert!(json.contains("\"downsample\":1.0"));
1328    }
1329
1330    #[test]
1331    fn test_slide_metadata_response_serialization() {
1332        let response = SlideMetadataResponse {
1333            slide_id: "path/to/slide.svs".to_string(),
1334            format: "aperio_svs".to_string(),
1335            width: 46920,
1336            height: 33600,
1337            level_count: 2,
1338            levels: vec![
1339                LevelMetadataResponse {
1340                    level: 0,
1341                    width: 46920,
1342                    height: 33600,
1343                    tile_width: 256,
1344                    tile_height: 256,
1345                    tiles_x: 184,
1346                    tiles_y: 132,
1347                    downsample: 1.0,
1348                },
1349                LevelMetadataResponse {
1350                    level: 1,
1351                    width: 23460,
1352                    height: 16800,
1353                    tile_width: 256,
1354                    tile_height: 256,
1355                    tiles_x: 92,
1356                    tiles_y: 66,
1357                    downsample: 2.0,
1358                },
1359            ],
1360        };
1361        let json = serde_json::to_string(&response).unwrap();
1362        assert!(json.contains("\"slide_id\":\"path/to/slide.svs\""));
1363        assert!(json.contains("\"format\":\"aperio_svs\""));
1364        assert!(json.contains("\"width\":46920"));
1365        assert!(json.contains("\"height\":33600"));
1366        assert!(json.contains("\"level_count\":2"));
1367        assert!(json.contains("\"levels\":["));
1368    }
1369
1370    #[test]
1371    fn test_slide_metadata_response_empty_levels() {
1372        let response = SlideMetadataResponse {
1373            slide_id: "empty.tif".to_string(),
1374            format: "generic_tiff".to_string(),
1375            width: 0,
1376            height: 0,
1377            level_count: 0,
1378            levels: vec![],
1379        };
1380        let json = serde_json::to_string(&response).unwrap();
1381        assert!(json.contains("\"levels\":[]"));
1382        assert!(json.contains("\"level_count\":0"));
1383    }
1384
1385    #[test]
1386    fn test_slide_metadata_error_to_status_code() {
1387        // Test NotFound -> 404
1388        let err = SlideMetadataError(FormatError::Io(IoError::NotFound(
1389            "bucket/slide.svs".to_string(),
1390        )));
1391        let response = err.into_response();
1392        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1393
1394        // Test UnsupportedFormat -> 415
1395        let err = SlideMetadataError(FormatError::UnsupportedFormat {
1396            reason: "not a TIFF file".to_string(),
1397        });
1398        let response = err.into_response();
1399        assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1400
1401        // Test S3 error -> 500
1402        let err = SlideMetadataError(FormatError::Io(IoError::S3("access denied".to_string())));
1403        let response = err.into_response();
1404        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1405    }
1406}