Databases, Connecting, Using, and Checking Errors

First, we're gonna expand the configuration we defined previously.  The one
thing we will require is the password, although eventually I'm going to make
that a command-line option using Clap.

``` rust
pub struct DatabaseSettings {
    pub username: String,
    pub password: String,
    pub host: String,
    pub port: u16,
    pub database: String,
}

pub struct Settings {
    pub database: DatabaseSettings,
    pub port: u16,
}

impl Default for DatabaseSettings {
    fn default() -> Self {
        DatabaseSettings {
            username: "newsletter".to_string(),
            password: "".to_string(),
            host: "localhost".to_string(),
            port: 5432,
            database: "newsletter".to_string(),
        }
    }
}

impl Default for Settings {
    fn default() -> Self {
        Settings {
            port: 3001,
            database: DatabaseSettings::default(),
        }
    }
}
```

The code's beginning to get a bit messsy. So let's re-arrange. I already have
the configuration handler in its own file, but let's clean up the routes. I'm
going to take the functions I've already defined and put them in a subfolder,
`src/routes`, and then for each one I'll make a new file.  For example:

``` rust
use axum::{http::StatusCode, response::IntoResponse};

pub(crate) async fn health_check() -> impl IntoResponse {
    (StatusCode::OK, ())
}
```

Just a note: *ignore* your IDE's "advice" to remove the unneeded `async`; Axum
will not compile this module correctly, `IntoResponse` requires that it be an
async function. I don't know why rust-analyzer failed to pick that up.

Now make a `src/routes.rs` (yes, the same name as the folder).  You can activate
and de-activate routes at will:

``` rust
use axum::{
    routing::{get, post},
    Router,
};

mod greet;
use greet::greet;

mod health_check;
use health_check::health_check;

mod index;
use index::index;

mod subscribe;
use subscribe::subscribe;

pub(crate) fn app() -> Router {
    Router::new()
        .route("/", get(index))
        .route("/subscriptions", post(subscribe))
        .route("/:name", get(greet))
        .route("/health_check", get(health_check))
}
```

You don't need the `use` clauses, you could just say `index::index`, but I kinda
like the way this looks, even with the repetition.  It makes it clear what I'm
doing and what I'm looking at.  This is Rust 2021, where the presence of a file
and a folder with the same name indicate a module with submodules.  In prior
editions of rust, instead of `routes.rs`, we would have used `routes/mod.rs`.
I'm not entirely sure which I like better.

Now that that's done, in our `lib.rs`, we use the `routes`:

``` rust
use std::net::SocketAddr;
mod configuration;
use configuration::get_configuration;
mod routes;
use routes::app;

pub async fn run() {
    let configuration = get_configuration().unwrap();
    let addr = SocketAddr::from(([127, 0, 0, 1], configuration.port));
    tracing::info!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app().into_make_service())
        .await
        .unwrap()
}

```

All of the Axum imports have been removed, since they're part of the routes.
Nice, clean and clear code.  At this point, even `cargo clippy` is happy.

Connecting to a database these days involves a URL.  Let's make it easy to
generate one.  Now, we could implement the trait `std::string::ToString`, or
`Display`, but that would conflict with any debugging information.  Instead,
let's define a new method in `configuration.rs`:

``` rust
impl DatabaseSettings {
    pub fn url(&self) -> String {
        if self.password.len() == 0 {
            format!(
                "postgres://{}@{}:{}/{}",
                self.username, self.host, self.port, self.database
            )
        } else {
            format!(
                "postgres://{}:{}@{}:{}/{}",
                self.username, self.password, self.host, self.port, self.database
            )
        }
    }
}
```

It is possible (unwise, but possible) to connect to a Postgres database with no
password.  In a dynamic language this would have been less wordy, but I can't
complain about Rust being a little extra verbose in exchange for being a lot
more precise.

Since we're using Postgres, in `lib.rs` we'll add `use
sqlx::postgres::PgPoolOptions;`, which will allow us to create a pool of workers
and limit how many there are.  In the `run()` function, we're going to extract
the `app()` definition and use it:

``` rust
use axum::Extension;
use sqlx::postgres::PgPoolOptions;

// in: fn run()

    let pool = PgPoolOptions::new()
        .max_connections(50)
        .connect(&configuration.database.url())
        .await
        .expect("could not connect to database_url");

    let routes = app().layer(Extension(pool));

    tracing::info!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(routes.into_make_service())
        .await
        .unwrap()
```

Now we have a configuration that is runnable.  If you run it as-is, it will fail
because, if you set up your Postgres database correctly, there is no password.
Create a `ztp.config.yaml` file (or JSON, or TOML, or whatever floats your boat)
and establish the password:

``` yaml
database:
    password: redacted
```

At one point, I had this file named only `ztp.config`, and unfortunately it
crashed.  Adding `#[derive(Debug)]` to my configuration structs helped show that
the password wasn't getting set, but it took me a few minutes to realize that
I'd forgotten the extension so `Config` didn't know how to read the file. Naming
it `ztp.config.yaml` fixed it, and this illustrates just how useful TOML can be
in circumstances like this.

This turned out to be a bit of a beast, and I'm incredibly indebted to Carlos
Armando Marcano Vargas and [his blog](https://github.com/carlosm27/blog) where
he documented much of what I needed to make this work with Axum.

In the migrations file, we specified that the primary key of our subscriptions
table would be a UUID, and that we were going to record when the subscription
happened.  To do this, first we have to make sure that we have the `Uuid` and
`Chrono` crates, and that they're all hooked up for proper serialization:

``` 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",
         "postgres", "uuid", "chrono"] }
```

Both of the new creates need `serde` as a feature and we'll be using `UUID4` for
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
pub(crate) struct NewSubscription {
    pub email: String,
    pub name: String,
}

struct Subscription {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub subscribed_at: DateTime<Utc>,
}

impl From<&NewSubscription> for Subscription {
    fn from(s: &NewSubscription) -> Self {
        Subscription {
            id: Uuid::new_v4(),
            email: s.email.clone(),
            name: s.name.clone(),
            subscribed_at: Utc::now(),
        }
    }
}
```

I've renamed "Subscription" to "NewSubscription" to distinguish it from the
thing we're going to reliably keep in our database, which has all the fields.

And now we're going to re-write the `subscribe()` function to actually talk to
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.

``` rust
pub(crate) async fn subscribe(Extension(pool): Extension<PgPool>,
                              payload:  Form<NewSubscription>) ->
    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)
        .bind(subscription.id)
        .bind(subscription.email)
        .bind(subscription.name)
        .bind(subscription.subscribed_at)
        .execute(&pool)
        .await
        .expect("Failed for some reason?");

    (StatusCode::OK, ())
}
```

Alternatively, I could have written:

``` rust
    let subscription = Subscription::from(&(payload.0));
```

Either way works.

That unused `.expect()` has Tokio panicking if I give it a duplicate email
address, as one of the constraints in our original migration reads:

``` sql
   email text not null unique,
```

We want to catch and handle that error.  We also want to fix something that's
been sitting in the "TODO" list for awhile: make sure that when the subscription
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<PgPool>,
    payload: Option<Form<NewSubscription>>,
) -> impl IntoResponse {
    if let Some(payload) = payload {
    ...
        (StatusCode::OK, ())
    } 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<T, impl Error>`,
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
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<PgPool>,
    payload: Option<Form<NewSubscription>>,
) -> Result<(StatusCode, ()), ZTPError> {
     // ...
            .map_or(Ok((StatusCode::OK, ())), |_| Err(ZTPError::DuplicateEmail))
    } else {
        Err(ZTPError::FormIncomplete)
    }
```

We've changed the function to take an `Option<Form<>>`; 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.

*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. ɡɡ
This commit is contained in:
Elf M. Sternberg 2023-03-24 15:09:39 -07:00
parent 5da8bb6b79
commit 9e401783f4
4 changed files with 91 additions and 14 deletions

View File

@ -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<PgPool>, payload: Form<NewSubscription>) -> 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<PgPool>,
payload: Form<NewSubscription>) ->
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<PgPool>,
payload: Option<Form<NewSubscription>>,
@ -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<T, impl Error>`,
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<PgPool>,
payload: Option<Form<NewSubscription>>,
) -> Result<(StatusCode, ()), ZTPError> {
// ...
.map_or(Ok((StatusCode::OK, ())), |_| Err(ZTPError::DuplicateEmail))
} else {
Err(ZTPError::FormIncomplete)
}
```
We've changed the function to take an `Option<Form<>>`; 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. ɡɡ

View File

@ -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

View File

@ -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()
}

View File

@ -1,3 +1,2 @@
database:
password: readthenews