diff --git a/docs/05-connecting-and-saving.md b/docs/05-connecting-and-saving.md index c4ecdc6..16e0a3f 100644 --- a/docs/05-connecting-and-saving.md +++ b/docs/05-connecting-and-saving.md @@ -219,7 +219,7 @@ happened. To do this, first we have to make sure that we have the `Uuid` and ``` toml chrono = { version = "0.4.24", features = ["serde"] } uuid = { version = "1.3.0", features = ["v4", "serde"] } -sqlx = { version = "0.6.3", features = ["runtime-tokio-native-tls", "macros", +sqlx = { version = "0.6.3", features = ["runtime-tokio-native-tls", "macros", "postgres", "uuid", "chrono"] } ``` @@ -229,7 +229,7 @@ our primary key. We'll also need a full subscription object to store, and then a way to generate it from a new subscription: -``` +```rust #[derive(serde::Deserialize)] pub(crate) struct NewSubscription { pub email: String, @@ -264,10 +264,12 @@ the database. All of those parens around the `payload` are necessary to help Rust understand what we want to borrow. You'll note that it's `payload.0`, not just `payload`; Form objects can have multiple instances of their content. - -``` -pub(crate) async fn subscribe(Extension(pool): Extension, payload: Form) -> impl IntoResponse { - let sql = "INSERT INTO subscriptions (id, email, name, subscribed_at) VALUES ($1, $2, $3, $4);".to_string(); +``` rust +pub(crate) async fn subscribe(Extension(pool): Extension, + payload: Form) -> + impl IntoResponse { + let sql = r#"INSERT INTO subscriptions (id, email, name, + subscribed_at) VALUES ($1, $2, $3, $4);"#.to_string(); let subscription: Subscription = (&(payload.0)).into(); let _ = sqlx::query(&sql) @@ -291,7 +293,7 @@ Alternatively, I could have written: Either way works. -### Better error handling. +### Better error handling That unused `.expect()` has Tokio panicking if I give it a duplicate email address, as one of the constraints in our original migration reads: @@ -306,7 +308,7 @@ form isn't fully filled out, we return a straight 400. That's actually the easier part: we make the payload optional and return our default `BAD_REQUEST` if it's not there: -``` +``` rust pub(crate) async fn subscribe( Extension(pool): Extension, payload: Option>, @@ -317,8 +319,84 @@ pub(crate) async fn subscribe( } else { (StatusCode::BAD_REQUEST, ()) } -``` +``` So, handling the errors is going to be interesting. Going above and beyond, it's time to use `thiserror`. +Rust has an excellent error handling story, with the `Result`, +although mostly this is just a way of distinguishing and annotating errors in a +robust, type-safe manner; it's still up to the programmer to handle errors. A +library has a set of error states that it can return, and `thiserror` is a set +of macros that automatically annotate your list of errors with messages and +values. + +Right now we have two possible errors: the form data passed in was incomplete, +or the email address submitted already exists. (We're not even checking the +email address for validity, just existence.) So let's create those errors and +provide them with labels. We'll just put this in a file named `errors.rs`, in +the root of the crate; that seems to be where everyone puts it, and it's a nice +convention. + +``` rust +#[derive(Error, Debug)] +pub enum ZTPError { + #[error("Form data was incomplete")] + FormIncomplete, + #[error("Email address already subscribed")] + DuplicateEmail, +} +``` + +And now the error will always be "400: Bad Response", but the text of the +message will be much more descriptive of what went wrong. + +We have to edit the `subscribe` function to handle these changes: first, the +return type must change, and then the errors must be included: + +``` rust +pub(crate) async fn subscribe( + Extension(pool): Extension, + payload: Option>, +) -> Result<(StatusCode, ()), ZTPError> { + // ... + .map_or(Ok((StatusCode::OK, ())), |_| Err(ZTPError::DuplicateEmail)) + } else { + Err(ZTPError::FormIncomplete) + } +``` + +We've changed the function to take an `Option>`; this way, if we don't +get a form that deserialized properly we'll be able to say "That wasn't a good +form," and we've changed the result so that we can return our error. Then we +changed our two edge cases to report the errors. + +Axum doesn't know what a ZTPError is, but we can easily make our errors to +something Axum does understand. We'll just have to implement our own +`IntoResponse`, and we'll put it in `errors.rs`: + +``` rust +impl IntoResponse for ZTPError { + fn into_response(self) -> axum::response::Response { + let (status, error_message) = match self { + Self::FormIncomplete => (StatusCode::BAD_REQUEST, self.to_string()), + Self::DuplicateEmail => (StatusCode::BAD_REQUEST, self.to_string()), + }; + (status, Json(json!({ "error": error_message }))).into_response() + } +} +``` + +... and with that, we can now change all of the tests to watch for `400` codes, +as that ancient TODO said, and it will all just work. + +### End of Chapter Three + +*Holy Chao*, we've made it through the "writing the application" stuff, and a +lot of what was in the book is... no longer quite so relevant. There's whole +sections on getting Actix to type-match with your code that Axum and 2021 Rust +make more or less irrelevant, and by using features like `thiserror` and +`dbmate` we've managed to route around many of the bulkier difficulties in the +book. + +The next chapter is about logging and monitoring. ɡɡ diff --git a/src/configuration.rs b/src/configuration.rs index 0ef1c3a..6dc9e02 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -12,7 +12,7 @@ pub struct DatabaseSettings { impl DatabaseSettings { pub fn url(&self) -> String { - if self.password.len() == 0 { + if self.password.is_empty() { format!( "postgres://{}@{}:{}/{}", self.username, self.host, self.port, self.database diff --git a/src/errors.rs b/src/errors.rs index 6128745..2c70a83 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -8,8 +8,8 @@ pub enum ZTPError { FormIncomplete, #[error("Email Address Already Subscribed")] DuplicateEmail, - #[error("Unknown error")] - Unknown, + // #[error("Unknown error")] + // Unknown, } impl IntoResponse for ZTPError { @@ -17,7 +17,7 @@ impl IntoResponse for ZTPError { let (status, error_message) = match self { Self::FormIncomplete => (StatusCode::BAD_REQUEST, self.to_string()), Self::DuplicateEmail => (StatusCode::BAD_REQUEST, self.to_string()), - Self::Unknown => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + // Self::Unknown => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), }; (status, Json(json!({ "error": error_message }))).into_response() } diff --git a/ztp.config.yaml b/ztp.config.yaml index 00e2683..09ffdce 100644 --- a/ztp.config.yaml +++ b/ztp.config.yaml @@ -1,3 +1,2 @@ database: password: readthenews -