From c55c4a49414f9dbcb637ba5ea765f4af9aebf807 Mon Sep 17 00:00:00 2001
From: Martin Fischer <martin@push-f.com>
Date: Tue, 19 Jan 2021 22:49:09 +0100
Subject: request: improve error handling with thiserror

bump version to 0.2.1
---
 Cargo.toml      |  5 ++--
 README.md       |  1 +
 src/error.rs    | 12 +++++++++
 src/lib.rs      |  2 +-
 src/request.rs  | 82 ++++++++++++++++++++++++++++++++++++++++++++++++---------
 src/security.rs | 18 ++++++++++---
 6 files changed, 101 insertions(+), 19 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index f974ccc..8cff0fb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "sputnik"
-version = "0.2.0"
+version = "0.2.1"
 authors = ["Martin Fischer <martin@push-f.com>"]
 license = "MIT"
 description = "A lightweight layer on top of hyper to facilitate building web applications."
@@ -22,4 +22,5 @@ httpdate = "0.3.2"
 mime = "0.3"
 rand = "0.7.3"
 sha2 = "0.9"
-time = "0.2"
\ No newline at end of file
+time = "0.2"
+thiserror = "1.0"
\ No newline at end of file
diff --git a/README.md b/README.md
index 04604fa..ff001c9 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Sputnik provides:
       (powered by the [cookie](https://crates.io/crates/cookie) crate)
     * parse query strings and HTML form data (powered by the
       [serde_urlencoded](https://crates.io/crates/serde_urlencoded) crate)
+* an `Error` struct that makes it easy to centrally control the presentation of all error messages
 * cookie-based [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery) tokens
 * `Key`: a convenience wrapper around HMAC (stolen from the cookie crate, so
   that you don't have to use `CookieJar`s if you don't need them)
diff --git a/src/error.rs b/src/error.rs
index d40ddf3..5f35da2 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -3,6 +3,8 @@ use std::fmt::Display;
 use hyper::StatusCode;
 
 /// Encapsulates a status code and an error message.
+///
+/// All client errors in [`crate::request`] implement [`Into<Error>`].
 #[derive(Debug)]
 pub struct Error {
     pub code: StatusCode,
@@ -41,4 +43,14 @@ impl Error {
     pub fn method_not_allowed(message: String) -> Self {
         Error{code: StatusCode::METHOD_NOT_ALLOWED, message}
     }
+}
+
+macro_rules! impl_into_http_error {
+    ($type:ident, $status:expr) => {
+       impl From<$type> for crate::error::Error {
+            fn from(err: $type) -> Self {
+                Self{code: $status, message: format!("{}", err)}
+            }
+       }
+    };
 }
\ No newline at end of file
diff --git a/src/lib.rs b/src/lib.rs
index 9719823..63b372b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,8 +4,8 @@ pub use error::Error;
 pub use mime;
 pub use httpdate;
 
+#[macro_use] mod error;
 pub mod security;
 pub mod request;
 pub mod response;
-mod error;
 mod signed;
\ No newline at end of file
diff --git a/src/request.rs b/src/request.rs
index 7846367..79ca2ae 100644
--- a/src/request.rs
+++ b/src/request.rs
@@ -8,7 +8,9 @@ use hyper::{body::Bytes, header};
 use hyper::http::request::Parts as ReqParts;
 use std::collections::HashMap;
 
-use crate::{Error, security};
+use crate::security;
+
+use error::*;
 
 type HyperRequest = hyper::Request<hyper::Body>;
 
@@ -72,14 +74,14 @@ impl Parts {
     }
 
     /// Parses the query string of the request into a given struct.
-    pub fn query<T: DeserializeOwned>(&self) -> Result<T,Error> {
-        serde_urlencoded::from_str::<T>(self.parts.uri.query().unwrap_or("")).map_err(|e|Error::bad_request(e.to_string()))
+    pub fn query<T: DeserializeOwned>(&self) -> Result<T,QueryError> {
+        serde_urlencoded::from_str::<T>(self.parts.uri.query().unwrap_or("")).map_err(QueryError)
     }
 }
 
 impl Body {
-    pub async fn into_bytes(self) -> Result<Bytes,Error> {
-        hyper::body::to_bytes(self.body).await.map_err(|_|Error::internal("failed to read body".to_string()))
+    pub async fn into_bytes(self) -> Result<Bytes, BodyError> {
+        hyper::body::to_bytes(self.body).await.map_err(BodyError)
     }
 
     /// Parses a `application/x-www-form-urlencoded` request body into a given struct.
@@ -102,10 +104,10 @@ impl Body {
     ///     Ok(Response::new(format!("hello {}", msg.text).into()))
     /// }
     /// ```
-    pub async fn into_form<T: DeserializeOwned>(self) -> Result<T,Error> {
+    pub async fn into_form<T: DeserializeOwned>(self) -> Result<T, FormError> {
         self.enforce_content_type(APPLICATION_WWW_FORM_URLENCODED)?;
         let full_body = self.into_bytes().await?;
-        serde_urlencoded::from_bytes::<T>(&full_body).map_err(|e|Error::bad_request(e.to_string()))
+        serde_urlencoded::from_bytes::<T>(&full_body).map_err(FormError::Deserialize)
     }
 
     /// Parses a `application/x-www-form-urlencoded` request body into a given struct.
@@ -140,20 +142,76 @@ impl Body {
     ///     Ok(response)
     /// }
     /// ```
-    pub async fn into_form_csrf<T: DeserializeOwned>(self, csrf_token: &security::CsrfToken) -> Result<T,Error> {
+    pub async fn into_form_csrf<T: DeserializeOwned>(self, csrf_token: &security::CsrfToken) -> Result<T, CsrfProtectedFormError> {
         self.enforce_content_type(APPLICATION_WWW_FORM_URLENCODED)?;
         let full_body = self.into_bytes().await?;
-        let csrf_data = serde_urlencoded::from_bytes::<CsrfData>(&full_body).map_err(|_|Error::bad_request("no csrf token".to_string()))?;
+        let csrf_data = serde_urlencoded::from_bytes::<CsrfData>(&full_body).map_err(|_| CsrfProtectedFormError::NoCsrf)?;
         csrf_token.matches(csrf_data.csrf)?;
-        serde_urlencoded::from_bytes::<T>(&full_body).map_err(|e|Error::bad_request(e.to_string()))
+        serde_urlencoded::from_bytes::<T>(&full_body).map_err(CsrfProtectedFormError::Deserialize)
     }
 
-    fn enforce_content_type(&self, mime: Mime) -> Result<(),Error> {
+    fn enforce_content_type(&self, mime: Mime) -> Result<(), WrongContentTypeError> {
         if let Some(content_type) = &self.content_type {
             if *content_type == mime.to_string() {
                 return Ok(())
             }
         }
-        Err(Error::bad_request(format!("expected content-type: {}", mime)))
+        Err(WrongContentTypeError{expected: mime, received: self.content_type.as_ref().and_then(|h| h.to_str().ok().map(|s| s.to_owned()))})
+    }
+}
+
+pub mod error {
+    use mime::Mime;
+    use thiserror::Error;
+    use hyper::StatusCode;
+
+    use crate::security::CsrfError;
+    #[derive(Error, Debug)]
+    #[error("query deserialize error: {0}")]
+    pub struct QueryError(pub serde_urlencoded::de::Error);
+    impl_into_http_error!(QueryError, StatusCode::BAD_REQUEST);
+
+    #[derive(Error, Debug)]
+    #[error("failed to read body")]
+    pub struct BodyError(pub hyper::Error);
+    impl_into_http_error!(BodyError, StatusCode::BAD_REQUEST);
+
+    #[derive(Error, Debug)]
+    #[error("expected Content-Type {expected} but received {}", received.as_ref().unwrap_or(&"nothing".to_owned()))]
+    pub struct WrongContentTypeError {
+        pub expected: Mime,
+        pub received: Option<String>,
+    }
+
+    #[derive(Error, Debug)]
+    pub enum FormError {
+        #[error("{0}")]
+        ContentType(#[from] WrongContentTypeError),
+
+        #[error("{0}")]
+        Body(#[from] BodyError),
+
+        #[error("form deserialize error: {0}")]
+        Deserialize(#[from] serde_urlencoded::de::Error),
+    }
+    impl_into_http_error!(FormError, StatusCode::BAD_REQUEST);
+
+    #[derive(Error, Debug)]
+    pub enum CsrfProtectedFormError {
+        #[error("{0}")]
+        ContentType(#[from] WrongContentTypeError),
+
+        #[error("{0}")]
+        Body(#[from] BodyError),
+
+        #[error("form deserialize error: {0}")]
+        Deserialize(#[from] serde_urlencoded::de::Error),
+
+        #[error("no csrf token in form data")]
+        NoCsrf,
+
+        #[error("{0}")]
+        Csrf(#[from] CsrfError),
     }
+    impl_into_http_error!(CsrfProtectedFormError, StatusCode::BAD_REQUEST);
 }
\ No newline at end of file
diff --git a/src/security.rs b/src/security.rs
index 4a17fe3..c12a97f 100644
--- a/src/security.rs
+++ b/src/security.rs
@@ -3,10 +3,11 @@
 use rand::{Rng, distributions::Alphanumeric};
 use ::cookie::Cookie;
 use time::{Duration, OffsetDateTime};
+use thiserror::Error;
 
 pub use crate::signed::Key;
 
-use crate::{Error, request::Parts, response::Response};
+use crate::{request::Parts, response::Response};
 
 /// A cookie-based CSRF token to be used with [`crate::request::Body::into_form_csrf`].
 pub struct CsrfToken {
@@ -14,6 +15,15 @@ pub struct CsrfToken {
     from_client: bool,
 }
 
+#[derive(Error, Debug)]
+pub enum CsrfError {
+    #[error("expected csrf cookie")]
+    NoCookie,
+
+    #[error("csrf parameter doesn't match csrf cookie")]
+    Mismatch,
+}
+
 impl CsrfToken {
     /// Retrieves the CSRF token from a `csrf` cookie or generates
     /// a new token and stores it as a cookie if it doesn't exist.
@@ -34,12 +44,12 @@ impl CsrfToken {
         format!("<input name=csrf type=hidden value=\"{}\">", self.token)
     }
 
-    pub(crate) fn matches(&self, str: String) -> Result<(), Error> {
+    pub(crate) fn matches(&self, str: String) -> Result<(), CsrfError> {
         if !self.from_client {
-            return Err(Error::bad_request("expected csrf cookie".to_string()))
+            return Err(CsrfError::NoCookie)
         }
         if self.token != str {
-            return Err(Error::bad_request("csrf parameter doesn't match csrf cookie".to_string()))
+            return Err(CsrfError::Mismatch)
         }
         Ok(())
     }
-- 
cgit v1.2.3