diff options
author | Martin Fischer <martin@push-f.com> | 2021-01-22 21:34:35 +0100 |
---|---|---|
committer | Martin Fischer <martin@push-f.com> | 2021-01-22 21:36:53 +0100 |
commit | 4ba2d050bdf1a3c0070f3aa2331c82745611af1f (patch) | |
tree | 710b2d96dba397da5d5888f5099402f6271dc9fd | |
parent | b886de1afc0b90d7ca27db9d5c7dabddbe3d7ee0 (diff) |
completely ditch Sputnik's general Error type
Users also want to short-circuit error types from other crates but they
cannot define a From conversion between two foreign types.
Sputnik's error type also didn't allow for proper error logging.
bump version to 0.2.3
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | README.md | 75 | ||||
-rw-r--r-- | examples/csrf/Cargo.toml | 3 | ||||
-rw-r--r-- | examples/csrf/src/main.rs | 32 | ||||
-rw-r--r-- | src/error.rs | 69 | ||||
-rw-r--r-- | src/lib.rs | 2 | ||||
-rw-r--r-- | src/request.rs | 24 |
7 files changed, 72 insertions, 135 deletions
@@ -1,6 +1,6 @@ [package] name = "sputnik" -version = "0.2.2" +version = "0.2.3" authors = ["Martin Fischer <martin@push-f.com>"] license = "MIT" description = "A lightweight layer on top of hyper to facilitate building web applications." @@ -1,7 +1,9 @@ # Sputnik -A lightweight layer on top of [Hyper](https://hyper.rs/) to facilitate -building web applications. +A microframework based on [Hyper](https://hyper.rs/) that forces you to: + +* make error handling explicit (no possible failures hidden behind macros) +* implement your own error type ([because you need to anyway](#error-handling)) Sputnik provides: @@ -10,8 +12,6 @@ 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` enum](#error-handling) 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) @@ -28,41 +28,48 @@ Sputnik does **not**: ## Error handling -Sputnik defines the following error types: - -```rust -pub struct SimpleError { - pub code: StatusCode, - pub message: String, -} +Rust provides convenient short-circuiting with the `?` operator, which +converts errors with `From::from()`. Since you probably want to short-circuit +errors from other crates (e.g. database errors), a web framework cannot +provide you an error type since Rust disallows you from defining a `From` +conversion between two foreign types. -pub enum Error { - Simple(SimpleError), - Response(hyper::Response<hyper::Body>), -} -``` +This does imply that you need to define your own error type, allowing you to +implement a `From` conversion for every error type you want to short-circuit +with `?`. Fortunately the [thiserror](https://crates.io/crates/thiserror) +crate makes defining custom errors and `From` implementations trivial. -Sputnik implements `Into<Error::Simple>` for all of its client error types -(e.g. deserialization errors), allowing you to easily customize the error -presentation. Sometimes however a `SimpleError` doesn't suffice, e.g. you -might want to redirect unauthorized users to your login page instead of -showing them an error, for such cases you can return an `Error::Response`. - -## CsrfToken example +## Example ```rust use std::convert::Infallible; use hyper::service::{service_fn, make_service_fn}; -use hyper::{Method, Server}; +use hyper::{Method, Server, StatusCode}; use serde::Deserialize; use sputnik::security::CsrfToken; -use sputnik::{Error, request::{Parts, Body}, response::Response}; +use sputnik::{request::{Parts, Body}, response::Response}; +use sputnik::request::error::*; + +#[derive(thiserror::Error, Debug)] +enum Error { + #[error("page not found")] + NotFound(String), + #[error("{0}")] + CsrfError(#[from] CsrfProtectedFormError) +} + +fn render_error(err: Error) -> (StatusCode, String) { + match err { + Error::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + Error::CsrfError(err) => (StatusCode::BAD_REQUEST, err.to_string()), + } +} async fn route(req: &mut Parts, body: Body) -> Result<Response,Error> { match (req.method(), req.uri().path()) { (&Method::GET, "/form") => get_form(req).await, (&Method::POST, "/form") => post_form(req, body).await, - _ => return Err(Error::not_found("page not found".to_owned())) + _ => return Err(Error::NotFound("page not found".to_owned())) } } @@ -90,12 +97,10 @@ async fn service(req: hyper::Request<hyper::Body>) -> Result<hyper::Response<hyp let (mut parts, body) = sputnik::request::adapt(req); match route(&mut parts, body).await { Ok(res) => Ok(res.into()), - Err(err) => match err { - Error::Simple(err) => { - Ok(err.response_builder().body(err.message.into()).unwrap()) - // you can easily wrap or log errors here - } - Error::Response(err) => Ok(err) + Err(err) => { + let (code, message) = render_error(err); + // you can easily wrap or log errors here + Ok(hyper::Response::builder().status(code).body(message.into()).unwrap()) } } } @@ -138,9 +143,9 @@ This session id cookie can then be retrieved and verified as follows: ```rust let userid = req.cookies().get("userid") - .ok_or_else(|| Error::unauthorized("expected userid cookie".to_owned())) - .and_then(|cookie| key.verify(cookie.value()).map_err(Error::unauthorized)) - .and_then(|value| decode_expiring_claim(value).map_err(|e| Error::unauthorized(format!("failed to decode userid cookie: {}", e))))?; + .ok_or_else(|| "expected userid cookie".to_owned()) + .and_then(|cookie| key.verify(cookie.value()) + .and_then(|value| decode_expiring_claim(value).map_err(|e| format!("failed to decode userid cookie: {}", e))); ``` Tip: If you want to store multiple claims in the cookie, you can diff --git a/examples/csrf/Cargo.toml b/examples/csrf/Cargo.toml index 1f0066b..b6768ed 100644 --- a/examples/csrf/Cargo.toml +++ b/examples/csrf/Cargo.toml @@ -11,4 +11,5 @@ publish = false hyper = "0.13" sputnik = {path = "../../"} serde = { version = "1.0", features = ["derive"] } -tokio = { version = "0.2", features = ["full"] }
\ No newline at end of file +tokio = { version = "0.2", features = ["full"] } +thiserror = "1.0"
\ No newline at end of file diff --git a/examples/csrf/src/main.rs b/examples/csrf/src/main.rs index 497bd66..915d063 100644 --- a/examples/csrf/src/main.rs +++ b/examples/csrf/src/main.rs @@ -1,15 +1,31 @@ use std::convert::Infallible; use hyper::service::{service_fn, make_service_fn}; -use hyper::{Method, Server}; +use hyper::{Method, Server, StatusCode}; use serde::Deserialize; use sputnik::security::CsrfToken; -use sputnik::{Error, request::{Parts, Body}, response::Response}; +use sputnik::{request::{Parts, Body}, response::Response}; +use sputnik::request::error::*; + +#[derive(thiserror::Error, Debug)] +enum Error { + #[error("page not found")] + NotFound(String), + #[error("{0}")] + CsrfError(#[from] CsrfProtectedFormError) +} + +fn render_error(err: Error) -> (StatusCode, String) { + match err { + Error::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + Error::CsrfError(err) => (StatusCode::BAD_REQUEST, err.to_string()), + } +} async fn route(req: &mut Parts, body: Body) -> Result<Response,Error> { match (req.method(), req.uri().path()) { (&Method::GET, "/form") => get_form(req).await, (&Method::POST, "/form") => post_form(req, body).await, - _ => return Err(Error::not_found("page not found".to_owned())) + _ => return Err(Error::NotFound("page not found".to_owned())) } } @@ -37,12 +53,10 @@ async fn service(req: hyper::Request<hyper::Body>) -> Result<hyper::Response<hyp let (mut parts, body) = sputnik::request::adapt(req); match route(&mut parts, body).await { Ok(res) => Ok(res.into()), - Err(err) => match err { - Error::Simple(err) => { - Ok(err.response_builder().body(err.message.into()).unwrap()) - // you can easily wrap or log errors here - } - Error::Response(err) => Ok(err) + Err(err) => { + let (code, message) = render_error(err); + // you can easily wrap or log errors here + Ok(hyper::Response::builder().status(code).body(message.into()).unwrap()) } } } diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index fe998d0..0000000 --- a/src/error.rs +++ /dev/null @@ -1,69 +0,0 @@ -//! Provides the [`crate::Error`] type. - -use thiserror::Error; -use hyper::StatusCode; - -/// Encapsulates a status code and an error message. -#[derive(Error, Debug)] -#[error("SimpleError({code}, {message})")] -pub struct SimpleError { - pub code: StatusCode, - pub message: String, -} - -impl SimpleError { - /// Returns an HTTP response builder with the status code set to `self.code`. - pub fn response_builder(&self) -> hyper::http::response::Builder { - hyper::Response::builder().status(self.code) - } -} - -/// Error type for request handlers. -/// -/// All client errors in [`crate::request`] implement [`Into<Error::Simple>`]. -#[derive(Error, Debug)] -pub enum Error { - #[error("SimpleError({}, {})", .0.code, .0.message)] - Simple(#[from] SimpleError), - - #[error("ResponseError({})", .0.status())] - Response(hyper::Response<hyper::Body>), -} - -impl Error { - // some convenience methods - - pub fn simple(code: StatusCode, message: String) -> Self { - Error::Simple(SimpleError{code, message}) - } - - pub fn bad_request(message: String) -> Self { - Error::Simple(SimpleError{code: StatusCode::BAD_REQUEST, message}) - } - - pub fn not_found(message: String) -> Self { - Error::Simple(SimpleError{code: StatusCode::NOT_FOUND, message}) - } - - pub fn unauthorized(message: String) -> Self { - Error::Simple(SimpleError{code: StatusCode::UNAUTHORIZED, message}) - } - - pub fn internal(message: String) -> Self { - Error::Simple(SimpleError{code: StatusCode::INTERNAL_SERVER_ERROR, message}) - } - - pub fn method_not_allowed(message: String) -> Self { - Error::Simple(SimpleError{code: StatusCode::METHOD_NOT_ALLOWED, message}) - } -} - -macro_rules! impl_into_error_simple { - ($type:ident, $status:expr) => { - impl From<$type> for crate::error::Error { - fn from(err: $type) -> Self { - Self::Simple(crate::error::SimpleError{code: $status, message: format!("{}", err)}) - } - } - }; -}
\ No newline at end of file @@ -1,10 +1,8 @@ //! A lightweight layer on top of [Hyper](https://hyper.rs/) //! to facilitate building web applications. -pub use error::Error; pub use mime; pub use httpdate; -#[macro_use] pub mod error; pub mod security; pub mod request; pub mod response; diff --git a/src/request.rs b/src/request.rs index c874ab5..953e7ec 100644 --- a/src/request.rs +++ b/src/request.rs @@ -93,13 +93,13 @@ impl Body { /// /// ``` /// use hyper::{Response}; - /// use sputnik::{request::Body, Error}; + /// use sputnik::request::{Body, error::FormError}; /// use serde::Deserialize; /// /// #[derive(Deserialize)] /// struct Message {text: String, year: i64} /// - /// async fn greet(body: Body) -> Result<Response<hyper::Body>, Error> { + /// async fn greet(body: Body) -> Result<Response<hyper::Body>, FormError> { /// let msg: Message = body.into_form().await?; /// Ok(Response::new(format!("hello {}", msg.text).into())) /// } @@ -120,25 +120,18 @@ impl Body { /// /// ``` /// use hyper::{Method}; - /// use sputnik::{request::{Parts, Body}, response::Response, Error}; + /// use sputnik::{request::{Parts, Body, error::CsrfProtectedFormError}, response::Response}; /// use sputnik::security::CsrfToken; /// use serde::Deserialize; /// /// #[derive(Deserialize)] /// struct Message {text: String} /// - /// async fn greet(req: &mut Parts, body: Body) -> Result<Response, Error> { + /// async fn greet(req: &mut Parts, body: Body) -> Result<Response, CsrfProtectedFormError> { /// let mut response = Response::new(); /// let csrf_token = CsrfToken::from_parts(req, &mut response); - /// *response.body() = match (req.method()) { - /// &Method::GET => format!("<form method=post> - /// <input name=text>{}<button>Submit</button></form>", csrf_token.html_input()).into(), - /// &Method::POST => { - /// let msg: Message = body.into_form_csrf(&csrf_token).await?; - /// format!("hello {}", msg.text).into() - /// }, - /// _ => return Err(Error::method_not_allowed("only GET and POST allowed".to_owned())), - /// }; + /// let msg: Message = body.into_form_csrf(&csrf_token).await?; + /// *response.body() = format!("hello {}", msg.text).into(); /// Ok(response) /// } /// ``` @@ -163,18 +156,15 @@ impl Body { 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_error_simple!(QueryError, StatusCode::BAD_REQUEST); #[derive(Error, Debug)] #[error("failed to read body")] pub struct BodyError(pub hyper::Error); - impl_into_error_simple!(BodyError, StatusCode::BAD_REQUEST); #[derive(Error, Debug)] #[error("expected Content-Type {expected} but received {}", received.as_ref().unwrap_or(&"nothing".to_owned()))] @@ -194,7 +184,6 @@ pub mod error { #[error("form deserialize error: {0}")] Deserialize(#[from] serde_urlencoded::de::Error), } - impl_into_error_simple!(FormError, StatusCode::BAD_REQUEST); #[derive(Error, Debug)] pub enum CsrfProtectedFormError { @@ -213,5 +202,4 @@ pub mod error { #[error("{0}")] Csrf(#[from] CsrfError), } - impl_into_error_simple!(CsrfProtectedFormError, StatusCode::BAD_REQUEST); }
\ No newline at end of file |