Interim commit.
This commit is contained in:
parent
2aa202d05c
commit
5da8bb6b79
|
@ -155,8 +155,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b"
|
checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"time",
|
||||||
|
"wasm-bindgen",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -487,7 +491,7 @@ checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -825,7 +829,7 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"wasi",
|
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1593,6 +1597,17 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.1.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi 0.10.0+wasi-snapshot-preview1",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
|
@ -1843,6 +1858,10 @@ name = "uuid"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79"
|
checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
|
@ -1872,6 +1891,12 @@ dependencies = [
|
||||||
"try-lock",
|
"try-lock",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.10.0+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.0+wasi-snapshot-preview1"
|
version = "0.11.0+wasi-snapshot-preview1"
|
||||||
|
@ -2087,12 +2112,16 @@ name = "ztp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"chrono",
|
||||||
"config",
|
"config",
|
||||||
"hyper",
|
"hyper",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
|
@ -12,14 +12,18 @@ name = "ztp"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.6.11"
|
axum = "0.6.11"
|
||||||
|
chrono = { version = "0.4.24", features = ["serde"] }
|
||||||
config = "0.13.3"
|
config = "0.13.3"
|
||||||
hyper = { version = "0.14.25", features = ["full"] }
|
hyper = { version = "0.14.25", features = ["full"] }
|
||||||
serde = { version = "1.0.158", features = ["derive"] }
|
serde = { version = "1.0.158", features = ["derive"] }
|
||||||
|
serde_json = "1.0.94"
|
||||||
sqlx = { version = "0.6.3", features = ["runtime-tokio-native-tls", "macros", "postgres", "uuid", "chrono"] }
|
sqlx = { version = "0.6.3", features = ["runtime-tokio-native-tls", "macros", "postgres", "uuid", "chrono"] }
|
||||||
|
thiserror = "1.0.40"
|
||||||
tokio = { version = "1.26.0", features = ["full"] }
|
tokio = { version = "1.26.0", features = ["full"] }
|
||||||
tower = "0.4.13"
|
tower = "0.4.13"
|
||||||
tracing = "0.1.35"
|
tracing = "0.1.35"
|
||||||
tracing-subscriber = "0.3.14"
|
tracing-subscriber = "0.3.14"
|
||||||
|
uuid = { version = "1.3.0", features = ["v4", "serde"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
|
@ -0,0 +1,324 @@
|
||||||
|
+++
|
||||||
|
title = "Databases: Connecting and Using"
|
||||||
|
date = 2023-03-20T17:38:12Z
|
||||||
|
weight = 4
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Chapter 3.8: Databases
|
||||||
|
|
||||||
|
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
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DatabaseSettings {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub database: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup on Aisle Three
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### The Database String
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Providing the database to the application
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Pulling in a subscription
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub(crate) struct NewSubscription {
|
||||||
|
pub email: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, sqlx::FromRow)]
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
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();
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
``` 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:
|
||||||
|
|
||||||
|
```
|
||||||
|
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`.
|
||||||
|
|
|
@ -1 +1,3 @@
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
max_width = 120
|
||||||
|
attr_fn_like_width = 120
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
use config::Config;
|
use config::Config;
|
||||||
|
|
||||||
/*
|
#[derive(serde::Deserialize, Debug)]
|
||||||
#[derive(serde::Deserialize)]
|
#[serde(default)]
|
||||||
pub struct DatabaseSettings {
|
pub struct DatabaseSettings {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
@ -10,23 +10,53 @@ pub struct DatabaseSettings {
|
||||||
pub database: String,
|
pub database: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
impl DatabaseSettings {
|
||||||
#[derive(serde::Deserialize)]
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, Debug)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
// pub database: DatabaseSettings,
|
pub database: DatabaseSettings,
|
||||||
pub port: u16,
|
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 {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Settings { port: 3001 }
|
Settings {
|
||||||
|
port: 3001,
|
||||||
|
database: DatabaseSettings::default(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_configuration() -> Result<Settings, config::ConfigError> {
|
pub fn get_configuration() -> Result<Settings, config::ConfigError> {
|
||||||
Config::builder()
|
Config::builder()
|
||||||
.add_source(config::File::with_name("./ztd.config").required(false))
|
.add_source(config::File::with_name("./ztp.config").required(false))
|
||||||
.build()?
|
.build()?
|
||||||
.try_deserialize()
|
.try_deserialize()
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||||
|
use serde_json::json;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ZTPError {
|
||||||
|
#[error("Form data was incomplete")]
|
||||||
|
FormIncomplete,
|
||||||
|
#[error("Email Address Already Subscribed")]
|
||||||
|
DuplicateEmail,
|
||||||
|
#[error("Unknown error")]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()),
|
||||||
|
Self::Unknown => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||||
|
};
|
||||||
|
(status, Json(json!({ "error": error_message }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
54
src/lib.rs
54
src/lib.rs
|
@ -1,42 +1,30 @@
|
||||||
use axum::{
|
use axum::{Extension, Router};
|
||||||
extract::Path,
|
|
||||||
http::StatusCode,
|
|
||||||
response::IntoResponse,
|
|
||||||
routing::{get, post},
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
pub mod configuration;
|
||||||
|
use configuration::{get_configuration, Settings};
|
||||||
|
mod errors;
|
||||||
|
mod routes;
|
||||||
|
use routes::routes;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
|
||||||
mod configuration;
|
pub async fn app(configuration: &Settings) -> Router {
|
||||||
mod user;
|
let pool = PgPoolOptions::new()
|
||||||
use configuration::get_configuration;
|
.max_connections(50)
|
||||||
use user::{index, subscribe};
|
.connect(&configuration.database.url())
|
||||||
|
.await
|
||||||
|
.expect("could not connect to database_url");
|
||||||
|
|
||||||
async fn greet(Path(name): Path<String>) -> impl IntoResponse {
|
routes().layer(Extension(pool))
|
||||||
let greeting = String::from("He's dead, ") + name.as_str();
|
|
||||||
let greeting = greeting + &String::from("!\n");
|
|
||||||
(StatusCode::OK, greeting)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn health_check() -> impl IntoResponse {
|
|
||||||
(StatusCode::OK, ())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn app() -> Router {
|
|
||||||
Router::new()
|
|
||||||
.route("/", get(index))
|
|
||||||
.route("/subscriptions", post(subscribe))
|
|
||||||
.route("/:name", get(greet))
|
|
||||||
.route("/health_check", get(health_check))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run() {
|
pub async fn run() {
|
||||||
let configuration = get_configuration().unwrap();
|
let configuration = get_configuration().unwrap();
|
||||||
|
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], configuration.port));
|
let addr = SocketAddr::from(([127, 0, 0, 1], configuration.port));
|
||||||
|
let routes = app(&configuration).await;
|
||||||
|
|
||||||
tracing::info!("listening on {}", addr);
|
tracing::info!("listening on {}", addr);
|
||||||
axum::Server::bind(&addr)
|
axum::Server::bind(&addr)
|
||||||
.serve(app().into_make_service())
|
.serve(routes.into_make_service())
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
@ -44,21 +32,19 @@ pub async fn run() {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use axum::{
|
use axum::{body::Body, http::Request};
|
||||||
body::Body,
|
|
||||||
http::{Request, StatusCode},
|
|
||||||
};
|
|
||||||
use std::net::{SocketAddr, TcpListener};
|
use std::net::{SocketAddr, TcpListener};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn the_real_deal() {
|
async fn the_real_deal() {
|
||||||
|
let configuration = get_configuration().unwrap();
|
||||||
let listener = TcpListener::bind("127.0.0.1:0".parse::<SocketAddr>().unwrap()).unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0".parse::<SocketAddr>().unwrap()).unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
axum::Server::from_tcp(listener)
|
axum::Server::from_tcp(listener)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.serve(app().into_make_service())
|
.serve(app(&configuration).await.into_make_service())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
mod greet;
|
||||||
|
mod health_check;
|
||||||
|
mod index;
|
||||||
|
mod subscribe;
|
||||||
|
|
||||||
|
use greet::greet;
|
||||||
|
use health_check::health_check;
|
||||||
|
use index::index;
|
||||||
|
use subscribe::subscribe;
|
||||||
|
|
||||||
|
pub(crate) fn routes() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(index))
|
||||||
|
.route("/subscriptions", post(subscribe))
|
||||||
|
.route("/:name", get(greet))
|
||||||
|
.route("/health_check", get(health_check))
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
use axum::{extract::Path, http::StatusCode, response::IntoResponse};
|
||||||
|
|
||||||
|
pub(crate) async fn greet(Path(name): Path<String>) -> impl IntoResponse {
|
||||||
|
let greeting = String::from("He's dead, ") + name.as_str();
|
||||||
|
let greeting = greeting + &String::from("!\n");
|
||||||
|
(StatusCode::OK, greeting)
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
use axum::{http::StatusCode, response::IntoResponse};
|
||||||
|
|
||||||
|
pub(crate) async fn health_check() -> impl IntoResponse {
|
||||||
|
(StatusCode::OK, ())
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
use axum::{http::StatusCode, response::IntoResponse, Form};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub(crate) struct IndexData {
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn index(payload: Option<Form<IndexData>>) -> impl IntoResponse {
|
||||||
|
let username = payload.map_or("World".to_string(), move |index| -> String {
|
||||||
|
String::from(&(index.username))
|
||||||
|
});
|
||||||
|
(StatusCode::OK, format!("Hello, {}!\n", &username))
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
use crate::errors::ZTPError;
|
||||||
|
use axum::{http::StatusCode, Extension, Form};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::types::Uuid;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub(crate) struct NewSubscription {
|
||||||
|
pub email: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, sqlx::FromRow)]
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn subscribe(
|
||||||
|
Extension(pool): Extension<PgPool>,
|
||||||
|
payload: Option<Form<NewSubscription>>,
|
||||||
|
) -> Result<(StatusCode, ()), ZTPError> {
|
||||||
|
if let Some(payload) = payload {
|
||||||
|
let sql = "INSERT INTO subscriptions (id, email, name, subscribed_at) VALUES ($1, $2, $3, $4);".to_string();
|
||||||
|
let subscription: Subscription = (&(payload.0)).into();
|
||||||
|
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(subscription.id)
|
||||||
|
.bind(subscription.email)
|
||||||
|
.bind(subscription.name)
|
||||||
|
.bind(subscription.subscribed_at)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_or(Ok((StatusCode::OK, ())), |_| Err(ZTPError::DuplicateEmail))
|
||||||
|
} else {
|
||||||
|
Err(ZTPError::FormIncomplete)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,4 @@
|
||||||
use axum::{
|
use axum::{body::Body, http::Request};
|
||||||
body::Body,
|
|
||||||
http::{Request, StatusCode},
|
|
||||||
};
|
|
||||||
use std::net::{SocketAddr, TcpListener};
|
use std::net::{SocketAddr, TcpListener};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use ztp::*;
|
use ztp::*;
|
||||||
|
@ -9,13 +6,14 @@ use ztp::*;
|
||||||
type NullHandle = JoinHandle<()>;
|
type NullHandle = JoinHandle<()>;
|
||||||
|
|
||||||
async fn spawn_server() -> (SocketAddr, NullHandle) {
|
async fn spawn_server() -> (SocketAddr, NullHandle) {
|
||||||
|
let configuration = ztp::configuration::get_configuration().unwrap();
|
||||||
let listener = TcpListener::bind("127.0.0.1:0".parse::<SocketAddr>().unwrap()).unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0".parse::<SocketAddr>().unwrap()).unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
let handle: NullHandle = tokio::spawn(async move {
|
let handle: NullHandle = tokio::spawn(async move {
|
||||||
axum::Server::from_tcp(listener)
|
axum::Server::from_tcp(listener)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.serve(app().into_make_service())
|
.serve(app(&configuration).await.into_make_service())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
@ -41,27 +39,6 @@ async fn test_for_hello_world() {
|
||||||
assert_eq!(&body[..], b"Hello, World!\n");
|
assert_eq!(&body[..], b"Hello, World!\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn valid_subscription() {
|
|
||||||
let (addr, _server_handle) = spawn_server().await;
|
|
||||||
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
|
|
||||||
|
|
||||||
let response = hyper::Client::new()
|
|
||||||
.request(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.header("content-type", "application/x-www-form-urlencoded")
|
|
||||||
.uri(format!("http://{}/subscriptions", addr))
|
|
||||||
.body(Body::from(body))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request.");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(200, response.status().as_u16());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn subscribe_returns_400_on_missing_data() {
|
async fn subscribe_returns_400_on_missing_data() {
|
||||||
let (addr, _server_handle) = spawn_server().await;
|
let (addr, _server_handle) = spawn_server().await;
|
||||||
|
@ -85,9 +62,8 @@ async fn subscribe_returns_400_on_missing_data() {
|
||||||
.await
|
.await
|
||||||
.expect("Failed to execute request.");
|
.expect("Failed to execute request.");
|
||||||
|
|
||||||
// TODO This should be 400 "Bad Request"
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
415,
|
400,
|
||||||
response.status().as_u16(),
|
response.status().as_u16(),
|
||||||
// Additional customised error message on test failure
|
// Additional customised error message on test failure
|
||||||
"The API did not fail with 400 Bad Request when the payload was {}.",
|
"The API did not fail with 400 Bad Request when the payload was {}.",
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
use axum::{body::Body, http::Request};
|
||||||
|
use sqlx::postgres::PgConnection;
|
||||||
|
use sqlx::Connection;
|
||||||
|
use std::net::{SocketAddr, TcpListener};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use ztp::*;
|
||||||
|
|
||||||
|
type NullHandle = JoinHandle<()>;
|
||||||
|
|
||||||
|
async fn spawn_server() -> (SocketAddr, NullHandle) {
|
||||||
|
let configuration = ztp::configuration::get_configuration().unwrap();
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0".parse::<SocketAddr>().unwrap()).unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let handle: NullHandle = tokio::spawn(async move {
|
||||||
|
axum::Server::from_tcp(listener)
|
||||||
|
.unwrap()
|
||||||
|
.serve(app(&configuration).await.into_make_service())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
(addr, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn valid_subscription() {
|
||||||
|
let (addr, _server_handle) = spawn_server().await;
|
||||||
|
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
|
||||||
|
|
||||||
|
let configuration = ztp::configuration::get_configuration().unwrap();
|
||||||
|
let mut connection = PgConnection::connect(&configuration.database.url())
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to Postgres.");
|
||||||
|
|
||||||
|
sqlx::query!("DELETE FROM subscriptions")
|
||||||
|
.execute(&mut connection)
|
||||||
|
.await
|
||||||
|
.expect("Failed to clear out the subscriptions table");
|
||||||
|
|
||||||
|
let response = hyper::Client::new()
|
||||||
|
.request(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.header("content-type", "application/x-www-form-urlencoded")
|
||||||
|
.uri(format!("http://{}/subscriptions", addr))
|
||||||
|
.body(Body::from(body))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert_eq!(200, response.status().as_u16());
|
||||||
|
|
||||||
|
let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
|
||||||
|
.fetch_one(&mut connection)
|
||||||
|
.await
|
||||||
|
.expect("Failed to fetch saved subscription.");
|
||||||
|
|
||||||
|
assert_eq!(saved.email, "ursula_le_guin@gmail.com");
|
||||||
|
assert_eq!(saved.name, "le guin");
|
||||||
|
|
||||||
|
sqlx::query!("DELETE FROM subscriptions")
|
||||||
|
.execute(&mut connection)
|
||||||
|
.await
|
||||||
|
.expect("Failed to clear out the subscriptions table");
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
database:
|
||||||
|
password: readthenews
|
||||||
|
|
Loading…
Reference in New Issue