From c9b733a4a667c82c665d84352624902dcba093a7 Mon Sep 17 00:00:00 2001 From: trinity-1686a Date: Sat, 14 Dec 2024 17:46:27 +0100 Subject: [PATCH] support redirection on s3 endpoint --- src/api/admin/bucket.rs | 1 + src/api/s3/get.rs | 44 ++++-- src/api/s3/website.rs | 131 ++++++++++++---- src/garage/admin/bucket.rs | 1 + src/model/bucket_table.rs | 54 +++++++ src/web/web_server.rs | 312 +++++++++++++++++++++++++++++-------- 6 files changed, 445 insertions(+), 98 deletions(-) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index ac3cba00..d5fd0e6b 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -423,6 +423,7 @@ pub async fn handle_update_bucket( "Please specify indexDocument when enabling website access.", )?, error_document: wa.error_document, + routing_rules: Vec::new(), })); } else { if wa.index_document.is_some() || wa.error_document.is_some() { diff --git a/src/api/s3/get.rs b/src/api/s3/get.rs index f5d3cf11..eea3434e 100644 --- a/src/api/s3/get.rs +++ b/src/api/s3/get.rs @@ -163,7 +163,15 @@ pub async fn handle_head( key: &str, part_number: Option, ) -> Result, Error> { - handle_head_without_ctx(ctx.garage, req, ctx.bucket_id, key, part_number).await + handle_head_without_ctx( + ctx.garage, + req, + ctx.bucket_id, + key, + StatusCode::OK, + part_number, + ) + .await } /// Handle HEAD request for website @@ -172,6 +180,7 @@ pub async fn handle_head_without_ctx( req: &Request, bucket_id: Uuid, key: &str, + status_code: StatusCode, part_number: Option, ) -> Result, Error> { let object = garage @@ -272,7 +281,7 @@ pub async fn handle_head_without_ctx( checksum_mode, ) .header(CONTENT_LENGTH, format!("{}", version_meta.size)) - .status(StatusCode::OK) + .status(status_code) .body(empty_body())?) } } @@ -285,7 +294,16 @@ pub async fn handle_get( part_number: Option, overrides: GetObjectOverrides, ) -> Result, Error> { - handle_get_without_ctx(ctx.garage, req, ctx.bucket_id, key, part_number, overrides).await + handle_get_without_ctx( + ctx.garage, + req, + ctx.bucket_id, + key, + StatusCode::OK, + part_number, + overrides, + ) + .await } /// Handle GET request @@ -294,6 +312,7 @@ pub async fn handle_get_without_ctx( req: &Request, bucket_id: Uuid, key: &str, + status_code: StatusCode, part_number: Option, overrides: GetObjectOverrides, ) -> Result, Error> { @@ -329,11 +348,15 @@ pub async fn handle_get_without_ctx( let checksum_mode = checksum_mode(&req); - match (part_number, parse_range_header(req, last_v_meta.size)?) { - (Some(_), Some(_)) => Err(Error::bad_request( + match ( + part_number, + parse_range_header(req, last_v_meta.size)?, + status_code == StatusCode::OK, + ) { + (Some(_), Some(_), _) => Err(Error::bad_request( "Cannot specify both partNumber and Range header", )), - (Some(pn), None) => { + (Some(pn), None, true) => { handle_get_part( garage, last_v, @@ -346,7 +369,7 @@ pub async fn handle_get_without_ctx( ) .await } - (None, Some(range)) => { + (None, Some(range), true) => { handle_get_range( garage, last_v, @@ -360,7 +383,8 @@ pub async fn handle_get_without_ctx( ) .await } - (None, None) => { + _ => { + // either not a range, or an error request: always return the full doc handle_get_full( garage, last_v, @@ -370,6 +394,7 @@ pub async fn handle_get_without_ctx( &headers, overrides, checksum_mode, + status_code, ) .await } @@ -385,6 +410,7 @@ async fn handle_get_full( meta_inner: &ObjectVersionMetaInner, overrides: GetObjectOverrides, checksum_mode: ChecksumMode, + status_code: StatusCode, ) -> Result, Error> { let mut resp_builder = object_headers( version, @@ -394,7 +420,7 @@ async fn handle_get_full( checksum_mode, ) .header(CONTENT_LENGTH, format!("{}", version_meta.size)) - .status(StatusCode::OK); + .status(status_code); getobject_override_headers(overrides, &mut resp_builder)?; let stream = full_object_byte_stream(garage, version, version_data, encryption); diff --git a/src/api/s3/website.rs b/src/api/s3/website.rs index 6af55677..934a20ff 100644 --- a/src/api/s3/website.rs +++ b/src/api/s3/website.rs @@ -10,7 +10,7 @@ use crate::s3::error::*; use crate::s3::xml::{to_xml_with_header, xmlns_tag, IntValue, Value}; use crate::signature::verify_signed_content; -use garage_model::bucket_table::*; +use garage_model::bucket_table::{self, *}; use garage_util::data::*; pub async fn handle_get_website(ctx: ReqCtx) -> Result, Error> { @@ -25,7 +25,8 @@ pub async fn handle_get_website(ctx: ReqCtx) -> Result, Error> suffix: Value(website.index_document.to_string()), }), redirect_all_requests_to: None, - routing_rules: None, + // TODO put the correct config here + routing_rules: Vec::new(), }; let xml = to_xml_with_header(&wc)?; Ok(Response::builder() @@ -101,8 +102,12 @@ pub struct WebsiteConfiguration { pub index_document: Option, #[serde(rename = "RedirectAllRequestsTo")] pub redirect_all_requests_to: Option, - #[serde(rename = "RoutingRules")] - pub routing_rules: Option>, + #[serde( + rename = "RoutingRules", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub routing_rules: Vec, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] @@ -166,7 +171,7 @@ impl WebsiteConfiguration { if self.redirect_all_requests_to.is_some() && (self.error_document.is_some() || self.index_document.is_some() - || self.routing_rules.is_some()) + || !self.routing_rules.is_empty()) { return Err(Error::bad_request( "Bad XML: can't have RedirectAllRequestsTo and other fields", @@ -181,10 +186,15 @@ impl WebsiteConfiguration { if let Some(ref rart) = self.redirect_all_requests_to { rart.validate()?; } - if let Some(ref rrs) = self.routing_rules { - for rr in rrs { - rr.inner.validate()?; - } + for rr in &self.routing_rules { + rr.inner.validate()?; + } + if self.routing_rules.len() > 1000 { + // we will do linear scans, best to avoid overly long configuration. The + // limit was choosen arbitrarily + return Err(Error::bad_request( + "Bad XML: RoutingRules can't have more than 1000 child elements", + )); } Ok(()) @@ -195,10 +205,12 @@ impl WebsiteConfiguration { Err(Error::NotImplemented( "S3 website redirects are not currently implemented in Garage.".into(), )) - } else if self.routing_rules.map(|x| !x.is_empty()).unwrap_or(false) { - Err(Error::NotImplemented( - "S3 routing rules are not currently implemented in Garage.".into(), - )) + /* + } else if self.routing_rules.map(|x| !x.is_empty()).unwrap_or(false) { + Err(Error::NotImplemented( + "S3 routing rules are not currently implemented in Garage.".into(), + )) + */ } else { Ok(WebsiteConfig { index_document: self @@ -206,6 +218,35 @@ impl WebsiteConfiguration { .map(|x| x.suffix.0) .unwrap_or_else(|| "index.html".to_string()), error_document: self.error_document.map(|x| x.key.0), + routing_rules: self + .routing_rules + .into_iter() + .map(|rule| { + bucket_table::RoutingRule { + condition: rule.inner.condition.map(|condition| { + bucket_table::Condition { + http_error_code: condition.http_error_code.map(|c| c.0 as u16), + prefix: condition.prefix.map(|p| p.0), + } + }), + redirect: bucket_table::Redirect { + hostname: rule.inner.redirect.hostname.map(|h| h.0), + protocol: rule.inner.redirect.protocol.map(|p| p.0), + // aws default to 301, which i find punitive in case of + // missconfiguration (can be permanently cached on the + // user agent) + http_redirect_code: rule + .inner + .redirect + .http_redirect_code + .map(|c| c.0 as u16) + .unwrap_or(302), + replace_key_prefix: rule.inner.redirect.replace_prefix.map(|k| k.0), + replace_key: rule.inner.redirect.replace_full.map(|k| k.0), + }, + } + }) + .collect(), }) } } @@ -248,35 +289,69 @@ impl Target { impl RoutingRuleInner { pub fn validate(&self) -> Result<(), Error> { - let has_prefix = self - .condition - .as_ref() - .and_then(|c| c.prefix.as_ref()) - .is_some(); - self.redirect.validate(has_prefix) + if let Some(condition) = &self.condition { + condition.validate()?; + } + self.redirect.validate() + } +} + +impl Condition { + pub fn validate(&self) -> Result { + if let Some(ref error_code) = self.http_error_code { + // TODO do other error codes make sense? Aws only allows 4xx and 5xx + if error_code.0 != 404 { + return Err(Error::bad_request( + "Bad XML: HttpErrorCodeReturnedEquals must be 404 or absent", + )); + } + } + Ok(self.prefix.is_some()) } } impl Redirect { - pub fn validate(&self, has_prefix: bool) -> Result<(), Error> { + pub fn validate(&self) -> Result<(), Error> { if self.replace_prefix.is_some() { if self.replace_full.is_some() { return Err(Error::bad_request( "Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set", )); } - if !has_prefix { - return Err(Error::bad_request( - "Bad XML: ReplaceKeyPrefixWith is set, but KeyPrefixEquals isn't", - )); - } } if let Some(ref protocol) = self.protocol { if protocol.0 != "http" && protocol.0 != "https" { return Err(Error::bad_request("Bad XML: invalid protocol")); } } - // TODO there are probably more invalide cases, but which ones? + if let Some(ref http_redirect_code) = self.http_redirect_code { + match http_redirect_code.0 { + // aws allows all 3xx except 300, but some are non-sensical (not modified, + // use proxy...) + 301 | 302 | 303 | 307 | 308 => { + if self.hostname.is_none() && self.protocol.is_some() { + return Err(Error::bad_request( + "Bad XML: HostName must be set if Protocol is set", + )); + } + } + // aws doesn't allow these codes, but netlify does, and it seems like a + // cool feature (change the page seen without changing the url shown by the + // user agent) + 200 | 404 => { + if self.hostname.is_some() || self.protocol.is_some() { + // hostname would mean different bucket, protocol doesn't make + // sense + return Err(Error::bad_request( + "Bad XML: an HttpRedirectCode of 200 is not acceptable alongside HostName or Protocol", + )); + } + } + _ => { + return Err(Error::bad_request("Bad XML: invalid HttpRedirectCode")); + } + } + } Ok(()) } } @@ -330,7 +405,7 @@ mod tests { hostname: Value("garage.tld".to_owned()), protocol: Some(Value("https".to_owned())), }), - routing_rules: Some(vec![RoutingRule { + routing_rules: vec![RoutingRule { inner: RoutingRuleInner { condition: Some(Condition { http_error_code: Some(IntValue(404)), @@ -344,7 +419,7 @@ mod tests { replace_full: Some(Value("fullkey".to_owned())), }, }, - }]), + }], }; assert_eq! { ref_value, diff --git a/src/garage/admin/bucket.rs b/src/garage/admin/bucket.rs index 1bdc6086..a9b4cc50 100644 --- a/src/garage/admin/bucket.rs +++ b/src/garage/admin/bucket.rs @@ -393,6 +393,7 @@ impl AdminRpcHandler { Some(WebsiteConfig { index_document: query.index_document.clone(), error_document: query.error_document.clone(), + routing_rules: Vec::new(), }) } else { None diff --git a/src/model/bucket_table.rs b/src/model/bucket_table.rs index 1dbdfac2..625c177d 100644 --- a/src/model/bucket_table.rs +++ b/src/model/bucket_table.rs @@ -60,6 +60,60 @@ mod v08 { pub struct WebsiteConfig { pub index_document: String, pub error_document: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub routing_rules: Vec, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct RoutingRule { + pub condition: Option, + pub redirect: Redirect, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct Condition { + pub http_error_code: Option, + pub prefix: Option, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct Redirect { + pub hostname: Option, + pub http_redirect_code: u16, + pub protocol: Option, + pub replace_key_prefix: Option, + pub replace_key: Option, + } + + impl Redirect { + pub fn compute_target(&self, suffix: Option<&str>) -> String { + let mut res = String::new(); + if let Some(hostname) = &self.hostname { + if let Some(protocol) = &self.protocol { + res.push_str(&protocol); + res.push_str("://"); + } else { + res.push_str("//"); + } + res.push_str(&hostname); + } + res.push('/'); + if let Some(replace_key_prefix) = &self.replace_key_prefix { + res.push_str(&replace_key_prefix); + if let Some(suffix) = suffix { + res.push_str(suffix) + } + } else if let Some(replace_key) = &self.replace_key { + res.push_str(&replace_key) + } + res + } + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct RedirectAll { + pub hostname: String, + pub protoco: String, } #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] diff --git a/src/web/web_server.rs b/src/web/web_server.rs index 69939f65..d8ce0460 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -28,6 +28,7 @@ use garage_api::s3::error::{ }; use garage_api::s3::get::{handle_get_without_ctx, handle_head_without_ctx}; +use garage_model::bucket_table::RoutingRule; use garage_model::garage::Garage; use garage_table::*; @@ -234,26 +235,32 @@ impl WebServer { // Get path let path = req.uri().path().to_string(); let index = &website_config.index_document; - let (key, may_redirect) = path_to_keys(&path, index)?; + let routing_result = path_to_keys(&path, index, &[])?; debug!( - "Selected bucket: \"{}\" {:?}, target key: \"{}\", may redirect to: {:?}", - bucket_name, bucket_id, key, may_redirect + "Selected bucket: \"{}\" {:?}, routing to {:?}", + bucket_name, bucket_id, routing_result, ); - let ret_doc = match *req.method() { - Method::OPTIONS => handle_options_for_bucket(req, &bucket_params) + let ret_doc = match (req.method(), routing_result.main_target()) { + (&Method::OPTIONS, _) => handle_options_for_bucket(req, &bucket_params) .map_err(ApiError::from) .map(|res| res.map(|_empty_body: EmptyBody| empty_body())), - Method::HEAD => { - handle_head_without_ctx(self.garage.clone(), req, bucket_id, &key, None).await + (_, Err((url, code))) => Ok(Response::builder() + .status(code) + .header("Location", url) + .body(empty_body()) + .unwrap()), + (&Method::HEAD, Ok((key, code))) => { + handle_head_without_ctx(self.garage.clone(), req, bucket_id, key, code, None).await } - Method::GET => { + (&Method::GET, Ok((key, code))) => { handle_get_without_ctx( self.garage.clone(), req, bucket_id, - &key, + key, + code, None, Default::default(), ) @@ -262,16 +269,68 @@ impl WebServer { _ => Err(ApiError::bad_request("HTTP method not supported")), }; - // Try implicit redirect on error - let ret_doc_with_redir = match (&ret_doc, may_redirect) { - (Err(ApiError::NoSuchKey), ImplicitRedirect::To { key, url }) - if self.check_key_exists(bucket_id, key.as_str()).await? => - { - Ok(Response::builder() - .status(StatusCode::FOUND) - .header("Location", url) - .body(empty_body()) - .unwrap()) + // Try handling errors if bucket configuration provided fallbacks + let ret_doc_with_redir = match (&ret_doc, &routing_result) { + ( + Err(ApiError::NoSuchKey), + RoutingResult::LoadOrRedirect { + redirect_if_exists, + redirect_url, + redirect_code, + .. + }, + ) => { + let redirect = if let Some(redirect_key) = redirect_if_exists { + self.check_key_exists(bucket_id, redirect_key.as_str()) + .await? + } else { + true + }; + if redirect { + Ok(Response::builder() + .status(redirect_code) + .header("Location", redirect_url) + .body(empty_body()) + .unwrap()) + } else { + ret_doc + } + } + ( + Err(ApiError::NoSuchKey), + RoutingResult::LoadOrAlternativeError { + redirect_key, + redirect_code, + .. + }, + ) => { + match *req.method() { + Method::HEAD => { + handle_head_without_ctx( + self.garage.clone(), + req, + bucket_id, + redirect_key, + *redirect_code, + None, + ) + .await + } + Method::GET => { + handle_get_without_ctx( + self.garage.clone(), + req, + bucket_id, + redirect_key, + *redirect_code, + None, + Default::default(), + ) + .await + } + // we shouldn't ever reach here + _ => Err(ApiError::bad_request("HTTP method not supported")), + } } _ => ret_doc, }; @@ -307,6 +366,7 @@ impl WebServer { &req2, bucket_id, &error_document, + error.http_status_code(), None, Default::default(), ) @@ -323,8 +383,6 @@ impl WebServer { error ); - *error_doc.status_mut() = error.http_status_code(); - // Preserve error message in a special header for error_line in error.to_string().split('\n') { if let Ok(v) = HeaderValue::from_bytes(error_line.as_bytes()) { @@ -371,9 +429,44 @@ fn error_to_res(e: Error) -> Response> { } #[derive(Debug, PartialEq)] -enum ImplicitRedirect { - No, - To { key: String, url: String }, +enum RoutingResult { + // Load a key and use `code` as status, or fallback to normal 404 handler if not found + LoadKey { + key: String, + code: StatusCode, + }, + // Load a key and use `200` as status, or fallback with a redirection using `redirect_code` + // as status + LoadOrRedirect { + key: String, + redirect_if_exists: Option, + redirect_url: String, + redirect_code: StatusCode, + }, + // Load a key and use `200` as status, or fallback by loading a different key and use + // `redirect_code` as status + LoadOrAlternativeError { + key: String, + redirect_key: String, + redirect_code: StatusCode, + }, + // Send an http redirect with `code` as status + Redirect { + url: String, + code: StatusCode, + }, +} + +impl RoutingResult { + // return Ok((key_to_deref, status_code)) or Err((redirect_target, status_code)) + fn main_target(&self) -> Result<(&str, StatusCode), (&str, StatusCode)> { + match self { + RoutingResult::LoadKey { key, code } => Ok((key, *code)), + RoutingResult::LoadOrRedirect { key, .. } => Ok((key, StatusCode::OK)), + RoutingResult::LoadOrAlternativeError { key, .. } => Ok((key, StatusCode::OK)), + RoutingResult::Redirect { url, code } => Err((url, *code)), + } + } } /// Path to key @@ -383,35 +476,128 @@ enum ImplicitRedirect { /// which is also AWS S3 behavior. /// /// Check: https://docs.aws.amazon.com/AmazonS3/latest/userguide/IndexDocumentSupport.html -fn path_to_keys<'a>(path: &'a str, index: &str) -> Result<(String, ImplicitRedirect), Error> { +fn path_to_keys<'a>( + path: &'a str, + index: &str, + routing_rules: &[RoutingRule], +) -> Result { let path_utf8 = percent_encoding::percent_decode_str(path).decode_utf8()?; let base_key = match path_utf8.strip_prefix("/") { Some(bk) => bk, None => return Err(Error::BadRequest("Path must start with a / (slash)".into())), }; + let is_bucket_root = base_key.len() == 0; let is_trailing_slash = path_utf8.ends_with("/"); - match (is_bucket_root, is_trailing_slash) { - // It is not possible to store something at the root of the bucket (ie. empty key), - // the only option is to fetch the index - (true, _) => Ok((index.to_string(), ImplicitRedirect::No)), + let key = if is_bucket_root || is_trailing_slash { + // we can't store anything at the root, so we need to query the index + // if the key end with a slash, we always query the index + format!("{base_key}{index}") + } else { + // if the key doesn't end with `/`, leave it unmodified + base_key.to_string() + }; - // "If you create a folder structure in your bucket, you must have an index document at each level. In each folder, the index document must have the same name, for example, index.html. When a user specifies a URL that resembles a folder lookup, the presence or absence of a trailing slash determines the behavior of the website. For example, the following URL, with a trailing slash, returns the photos/index.html index document." - (false, true) => Ok((format!("{base_key}{index}"), ImplicitRedirect::No)), + let mut routing_rules_iter = routing_rules.iter(); + let key = loop { + let Some(routing_rule) = routing_rules_iter.next() else { + break key; + }; - // "However, if you exclude the trailing slash from the preceding URL, Amazon S3 first looks for an object photos in the bucket. If the photos object is not found, it searches for an index document, photos/index.html. If that document is found, Amazon S3 returns a 302 Found message and points to the photos/ key. For subsequent requests to photos/, Amazon S3 returns photos/index.html. If the index document is not found, Amazon S3 returns an error." - (false, false) => Ok(( - base_key.to_string(), - ImplicitRedirect::To { - key: format!("{base_key}/{index}"), - url: format!("{path}/"), - }, - )), + let Ok(status_code) = StatusCode::from_u16(routing_rule.redirect.http_redirect_code) else { + continue; + }; + if let Some(condition) = &routing_rule.condition { + let suffix = if let Some(prefix) = &condition.prefix { + let Some(suffix) = key.strip_prefix(prefix) else { + continue; + }; + Some(suffix) + } else { + None + }; + let target = routing_rule.redirect.compute_target(suffix); + let query_alternative_key = + status_code == StatusCode::OK || status_code == StatusCode::NOT_FOUND; + let redirect_on_error = + condition.http_error_code == Some(StatusCode::NOT_FOUND.as_u16()); + match (query_alternative_key, redirect_on_error) { + (false, false) => { + return Ok(RoutingResult::Redirect { + url: target, + code: status_code, + }) + } + (true, false) => { + if status_code == StatusCode::OK { + break target; + } else { + return Ok(RoutingResult::LoadKey { + key: target, + code: status_code, + }); + } + } + (false, true) => { + return Ok(RoutingResult::LoadOrRedirect { + key, + redirect_if_exists: None, + redirect_url: target, + redirect_code: status_code, + }); + } + (true, true) => { + return Ok(RoutingResult::LoadOrAlternativeError { + key, + redirect_key: target, + redirect_code: status_code, + }); + } + } + } else { + let target = routing_rule.redirect.compute_target(None); + return Ok(RoutingResult::Redirect { + url: target, + code: status_code, + }); + } + }; + + if is_bucket_root || is_trailing_slash { + Ok(RoutingResult::LoadKey { + key, + code: StatusCode::OK, + }) + } else { + Ok(RoutingResult::LoadOrRedirect { + redirect_if_exists: Some(format!("{key}/{index}")), + key, + // we can't use `path` because key might have changed substentially in case of + // routing rules + redirect_url: percent_encoding::percent_encode( + format!("{path}/").as_bytes(), + PATH_ENCODING_SET, + ) + .to_string(), + redirect_code: StatusCode::FOUND, + }) } } +// per https://url.spec.whatwg.org/#path-percent-encode-set +const PATH_ENCODING_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + #[cfg(test)] mod tests { use super::*; @@ -419,35 +605,39 @@ mod tests { #[test] fn path_to_keys_test() -> Result<(), Error> { assert_eq!( - path_to_keys("/file%20.jpg", "index.html")?, - ( - "file .jpg".to_string(), - ImplicitRedirect::To { - key: "file .jpg/index.html".to_string(), - url: "/file%20.jpg/".to_string() - } - ) + path_to_keys("/file%20.jpg", "index.html", &[])?, + RoutingResult::LoadOrRedirect { + key: "file .jpg".to_string(), + redirect_url: "/file%20.jpg/".to_string(), + redirect_if_exists: Some("file .jpg/index.html".to_string()), + redirect_code: StatusCode::FOUND, + } ); assert_eq!( - path_to_keys("/%20t/", "index.html")?, - (" t/index.html".to_string(), ImplicitRedirect::No) + path_to_keys("/%20t/", "index.html", &[])?, + RoutingResult::LoadKey { + key: " t/index.html".to_string(), + code: StatusCode::OK + } ); assert_eq!( - path_to_keys("/", "index.html")?, - ("index.html".to_string(), ImplicitRedirect::No) + path_to_keys("/", "index.html", &[])?, + RoutingResult::LoadKey { + key: "index.html".to_string(), + code: StatusCode::OK + } ); assert_eq!( - path_to_keys("/hello", "index.html")?, - ( - "hello".to_string(), - ImplicitRedirect::To { - key: "hello/index.html".to_string(), - url: "/hello/".to_string() - } - ) + path_to_keys("/hello", "index.html", &[])?, + RoutingResult::LoadOrRedirect { + key: "hello".to_string(), + redirect_url: "/hello/".to_string(), + redirect_if_exists: Some("hello/index.html".to_string()), + redirect_code: StatusCode::FOUND, + } ); - assert!(path_to_keys("", "index.html").is_err()); - assert!(path_to_keys("i/am/relative", "index.html").is_err()); + assert!(path_to_keys("", "index.html", &[]).is_err()); + assert!(path_to_keys("i/am/relative", "index.html", &[]).is_err()); Ok(()) } }