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"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"time",
|
||||
"wasm-bindgen",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
|
@ -487,7 +491,7 @@ checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -825,7 +829,7 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9"
|
|||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
|
@ -1593,6 +1597,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "tinyvec"
|
||||
version = "1.6.0"
|
||||
|
@ -1843,6 +1858,10 @@ name = "uuid"
|
|||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
|
@ -1872,6 +1891,12 @@ dependencies = [
|
|||
"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]]
|
||||
name = "wasi"
|
||||
version = "0.11.0+wasi-snapshot-preview1"
|
||||
|
@ -2087,12 +2112,16 @@ name = "ztp"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"config",
|
||||
"hyper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
|
|
@ -12,14 +12,18 @@ name = "ztp"
|
|||
|
||||
[dependencies]
|
||||
axum = "0.6.11"
|
||||
chrono = { version = "0.4.24", features = ["serde"] }
|
||||
config = "0.13.3"
|
||||
hyper = { version = "0.14.25", features = ["full"] }
|
||||
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"] }
|
||||
thiserror = "1.0.40"
|
||||
tokio = { version = "1.26.0", features = ["full"] }
|
||||
tower = "0.4.13"
|
||||
tracing = "0.1.35"
|
||||
tracing-subscriber = "0.3.14"
|
||||
uuid = { version = "1.3.0", features = ["v4", "serde"] }
|
||||
|
||||
[profile.release]
|
||||
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"
|
||||
max_width = 120
|
||||
attr_fn_like_width = 120
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use config::Config;
|
||||
|
||||
/*
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct DatabaseSettings {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
|
@ -10,23 +10,53 @@ pub struct DatabaseSettings {
|
|||
pub database: String,
|
||||
}
|
||||
|
||||
*/
|
||||
#[derive(serde::Deserialize)]
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
// pub database: DatabaseSettings,
|
||||
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 }
|
||||
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()
|
||||
.add_source(config::File::with_name("./ztd.config").required(false))
|
||||
.add_source(config::File::with_name("./ztp.config").required(false))
|
||||
.build()?
|
||||
.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::{
|
||||
extract::Path,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use axum::{Extension, Router};
|
||||
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;
|
||||
mod user;
|
||||
use configuration::get_configuration;
|
||||
use user::{index, subscribe};
|
||||
pub async fn app(configuration: &Settings) -> Router {
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(50)
|
||||
.connect(&configuration.database.url())
|
||||
.await
|
||||
.expect("could not connect to database_url");
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
routes().layer(Extension(pool))
|
||||
}
|
||||
|
||||
pub async fn run() {
|
||||
let configuration = get_configuration().unwrap();
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], configuration.port));
|
||||
let routes = app(&configuration).await;
|
||||
|
||||
tracing::info!("listening on {}", addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app().into_make_service())
|
||||
.serve(routes.into_make_service())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
@ -44,21 +32,19 @@ pub async fn run() {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use axum::{body::Body, http::Request};
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_real_deal() {
|
||||
let configuration = get_configuration().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0".parse::<SocketAddr>().unwrap()).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::Server::from_tcp(listener)
|
||||
.unwrap()
|
||||
.serve(app().into_make_service())
|
||||
.serve(app(&configuration).await.into_make_service())
|
||||
.await
|
||||
.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::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use axum::{body::Body, http::Request};
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
use tokio::task::JoinHandle;
|
||||
use ztp::*;
|
||||
|
@ -9,13 +6,14 @@ 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().into_make_service())
|
||||
.serve(app(&configuration).await.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
@ -41,27 +39,6 @@ async fn test_for_hello_world() {
|
|||
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]
|
||||
async fn subscribe_returns_400_on_missing_data() {
|
||||
let (addr, _server_handle) = spawn_server().await;
|
||||
|
@ -85,9 +62,8 @@ async fn subscribe_returns_400_on_missing_data() {
|
|||
.await
|
||||
.expect("Failed to execute request.");
|
||||
|
||||
// TODO This should be 400 "Bad Request"
|
||||
assert_eq!(
|
||||
415,
|
||||
400,
|
||||
response.status().as_u16(),
|
||||
// Additional customised error message on test failure
|
||||
"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