1use 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
28pub struct AppState<S: SlideSource> {
36 pub tile_service: Arc<TileService<S>>,
38
39 pub cache_max_age: u32,
41
42 pub auth: Option<SignedUrlAuth>,
44}
45
46impl<S: SlideSource> AppState<S> {
47 pub fn new(tile_service: TileService<S>) -> Self {
49 Self {
50 tile_service: Arc::new(tile_service),
51 cache_max_age: 3600, auth: None,
53 }
54 }
55
56 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 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#[derive(Debug, Deserialize)]
91pub struct TilePathParams {
92 pub slide_id: String,
94
95 pub level: usize,
97
98 pub x: u32,
100
101 pub filename: String,
103}
104
105impl TilePathParams {
106 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#[derive(Debug, Deserialize)]
115pub struct TileQueryParams {
116 #[serde(default = "default_quality")]
118 pub quality: u8,
119
120 #[serde(default)]
122 pub sig: Option<String>,
123
124 #[serde(default)]
126 pub exp: Option<u64>,
127}
128
129fn default_quality() -> u8 {
130 DEFAULT_JPEG_QUALITY
131}
132
133#[derive(Debug, Deserialize)]
135pub struct SlidesQueryParams {
136 #[serde(default = "default_limit")]
138 pub limit: u32,
139
140 #[serde(default)]
142 pub cursor: Option<String>,
143
144 #[serde(default)]
146 pub prefix: Option<String>,
147
148 #[serde(default)]
150 pub search: Option<String>,
151
152 #[serde(default)]
154 pub sig: Option<String>,
155
156 #[serde(default)]
158 pub exp: Option<u64>,
159}
160
161fn default_limit() -> u32 {
162 100
163}
164
165#[derive(Debug, Deserialize)]
167pub struct ThumbnailQueryParams {
168 #[serde(default = "default_thumbnail_size")]
170 pub max_size: u32,
171
172 #[serde(default = "default_quality")]
174 pub quality: u8,
175
176 #[serde(default)]
178 pub sig: Option<String>,
179
180 #[serde(default)]
182 pub exp: Option<u64>,
183}
184
185fn default_thumbnail_size() -> u32 {
186 512
187}
188
189#[derive(Debug, Serialize)]
195pub struct ErrorResponse {
196 pub error: String,
198
199 pub message: String,
201
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub status: Option<u16>,
205}
206
207impl ErrorResponse {
208 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 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#[derive(Debug, Serialize)]
233pub struct HealthResponse {
234 pub status: String,
236
237 pub version: String,
239}
240
241#[derive(Debug, Serialize)]
243pub struct SlidesResponse {
244 pub slides: Vec<String>,
246
247 #[serde(skip_serializing_if = "Option::is_none")]
249 pub next_cursor: Option<String>,
250}
251
252#[derive(Debug, Serialize)]
254pub struct LevelMetadataResponse {
255 pub level: usize,
257
258 pub width: u32,
260
261 pub height: u32,
263
264 pub tile_width: u32,
266
267 pub tile_height: u32,
269
270 pub tiles_x: u32,
272
273 pub tiles_y: u32,
275
276 pub downsample: f64,
278}
279
280#[derive(Debug, Serialize)]
282pub struct SlideMetadataResponse {
283 pub slide_id: String,
285
286 pub format: String,
288
289 pub width: u32,
291
292 pub height: u32,
294
295 pub level_count: usize,
297
298 pub levels: Vec<LevelMetadataResponse>,
300}
301
302impl IntoResponse for TileError {
312 fn into_response(self) -> Response {
313 let (status, error_type, message) = match &self {
314 TileError::SlideNotFound { slide_id } => (
316 StatusCode::NOT_FOUND,
317 "not_found",
318 format!("Slide not found: {}", slide_id),
319 ),
320
321 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 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 TileError::Io(io_err) => {
379 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 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 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
440impl 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 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
537pub 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
552pub 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 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
608pub 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
623pub 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 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 let request =
677 TileRequest::with_quality(¶ms.slide_id, params.level, params.x, y, query.quality);
678
679 let response = state.tile_service.get_tile(request).await?;
681
682 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
698pub 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
720pub async fn slides_handler<S: SlideSource>(
748 State(state): State<AppState<S>>,
749 Query(query): Query<SlidesQueryParams>,
750) -> Result<Json<SlidesResponse>, SlidesError> {
751 let limit = query.limit.clamp(1, 1000);
753
754 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 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
780pub 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 let slide = state.tile_service.registry().get_slide(&slide_id).await?;
827
828 let (width, height) = slide.dimensions().unwrap_or((0, 0));
830
831 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
858pub 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 let slide = state.tile_service.registry().get_slide(&slide_id).await?;
884
885 let (width, height) = slide.dimensions().unwrap_or((0, 0));
887
888 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 let host = headers
916 .get(header::HOST)
917 .and_then(|h| h.to_str().ok())
918 .unwrap_or("localhost:3000");
919
920 let proto = headers
923 .get("x-forwarded-proto")
924 .and_then(|h| h.to_str().ok())
925 .unwrap_or("http");
926
927 let base_url = format!("{}://{}", proto, host);
929
930 let auth_query = state
933 .auth
934 .as_ref()
935 .map(|auth| {
936 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 let html = super::viewer::generate_viewer_html(&slide_id, &metadata, &base_url, &auth_query);
945
946 Ok(Html(html))
947}
948
949pub async fn dzi_descriptor_handler<S: SlideSource>(
981 State(state): State<AppState<S>>,
982 Path(slide_id): Path<String>,
983) -> Result<Response, SlideMetadataError> {
984 let slide = state.tile_service.registry().get_slide(&slide_id).await?;
986
987 let (width, height) = slide.dimensions().unwrap_or((0, 0));
989
990 let tile_size = slide.tile_size(0).map(|(w, _)| w).unwrap_or(256);
992
993 let xml = super::dzi::generate_dzi_xml(width, height, tile_size);
995
996 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
1010pub 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 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 let response = state
1049 .tile_service
1050 .generate_thumbnail(&slide_id, max_size, query.quality)
1051 .await?;
1052
1053 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 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#[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")); }
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 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 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 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 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 let err = TileError::Slide(TiffError::StripOrganization);
1138 let response = err.into_response();
1139 assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1140
1141 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 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 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 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 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 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 let err = FormatError::Tiff(TiffError::StripOrganization);
1202 let response = err.into_response();
1203 assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1204
1205 let err = FormatError::Tiff(TiffError::InvalidMagic(0x1234));
1207 let response = err.into_response();
1208 assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1209
1210 let err = FormatError::Tiff(TiffError::InvalidVersion(99));
1212 let response = err.into_response();
1213 assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
1214
1215 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 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 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 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 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 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 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 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 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 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 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}