Compare commits
2 Commits
8b1fbec3b2
...
a89cbe5bb0
Author | SHA1 | Date |
---|---|---|
|
a89cbe5bb0 | |
|
89fb8188b7 |
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
commands-show-output: false
|
||||||
|
no-inline-html: false
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
# See https://pre-commit.com for more information
|
||||||
|
# See https://pre-commit.com/hooks.html for more hooks
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v3.1.0
|
||||||
|
hooks:
|
||||||
|
- id: check-byte-order-marker
|
||||||
|
- id: check-case-conflict
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: check-symlinks
|
||||||
|
- id: check-yaml
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: mixed-line-ending
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit
|
||||||
|
rev: v2.5.1
|
||||||
|
hooks:
|
||||||
|
- id: validate_manifest
|
||||||
|
- repo: https://github.com/doublify/pre-commit-rust
|
||||||
|
rev: eeee35a8
|
||||||
|
hooks:
|
||||||
|
- id: fmt
|
||||||
|
- id: cargo-check
|
||||||
|
- id: clippy
|
||||||
|
- repo: https://github.com/DavidAnson/markdownlint-cli2
|
||||||
|
rev: v0.6.0
|
||||||
|
hooks:
|
||||||
|
- id: markdownlint-cli2
|
|
@ -452,6 +452,20 @@ name = "serde"
|
||||||
version = "1.0.158"
|
version = "1.0.158"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "771d4d9c4163ee138805e12c710dd365e4f44be8be0503cb1bb9eb989425d9c9"
|
checksum = "771d4d9c4163ee138805e12c710dd365e4f44be8be0503cb1bb9eb989425d9c9"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.158"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e801c1712f48475582b7696ac71e0ca34ebb30e09338425384269d9717c62cad"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
|
@ -826,6 +840,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"serde",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
|
@ -13,8 +13,13 @@ name = "ztp"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.6.11"
|
axum = "0.6.11"
|
||||||
hyper = { version = "0.14.25", features = ["full"] }
|
hyper = { version = "0.14.25", features = ["full"] }
|
||||||
|
serde = { version = "1.0.158", features = ["derive"] }
|
||||||
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"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
|
|
@ -12,20 +12,20 @@ tool. It is one of those blind-trust-in-the-safety-of-the-toolchain things. For
|
||||||
Linux and Mac users, the command is a shell script that installs to a user's
|
Linux and Mac users, the command is a shell script that installs to a user's
|
||||||
local account:
|
local account:
|
||||||
|
|
||||||
```
|
``` sh
|
||||||
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Once installed, you can install Rust itself:
|
Once installed, you can install Rust itself:
|
||||||
|
|
||||||
```
|
``` sh
|
||||||
$ rustup install toolchain stable
|
$ rustup install toolchain stable
|
||||||
```
|
```
|
||||||
|
|
||||||
You should now have Rust compiler and the Rust build and packaging tool, known
|
You should now have Rust compiler and the Rust build and packaging tool, known
|
||||||
as Cargo:
|
as Cargo:
|
||||||
|
|
||||||
```
|
``` sh
|
||||||
$ rustc --version
|
$ rustc --version
|
||||||
rustc 1.68.0 (2c8cc3432 2023-03-06)
|
rustc 1.68.0 (2c8cc3432 2023-03-06)
|
||||||
$ cargo --version
|
$ cargo --version
|
||||||
|
@ -34,7 +34,7 @@ cargo 1.68.0 (115f34552 2023-02-26)
|
||||||
|
|
||||||
I also installed the following tools:
|
I also installed the following tools:
|
||||||
|
|
||||||
```
|
``` sh
|
||||||
$ rustup component add clippy rust-src rust-docs
|
$ rustup component add clippy rust-src rust-docs
|
||||||
$ cargo install rustfmt rust-analyzer
|
$ cargo install rustfmt rust-analyzer
|
||||||
```
|
```
|
||||||
|
@ -45,5 +45,3 @@ $ cargo install rustfmt rust-analyzer
|
||||||
- rust-analyzer: For your IDE, rust-analyzer provides the LSP (Language Server
|
- rust-analyzer: For your IDE, rust-analyzer provides the LSP (Language Server
|
||||||
Protocol) for Rust, giving you code completion, on-the-fly error definition,
|
Protocol) for Rust, giving you code completion, on-the-fly error definition,
|
||||||
and other luxuries.
|
and other luxuries.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -16,12 +16,11 @@ Although the book is only two years old, it is already out-of-date with respect
|
||||||
to some commands. `cargo add` is now provided by default. The following
|
to some commands. `cargo add` is now provided by default. The following
|
||||||
commands installed the tools I'll be using:
|
commands installed the tools I'll be using:
|
||||||
|
|
||||||
```
|
``` sh
|
||||||
cargo add --features tokio/full --features hyper/full tokio hyper \
|
cargo add --features tokio/full --features hyper/full tokio hyper \
|
||||||
axum tracing tracing-subscriber
|
axum tracing tracing-subscriber
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
- axum: The web server framework for Tokio.
|
- axum: The web server framework for Tokio.
|
||||||
- tokio: The Rust asynchronous runtime. Has single-threaded (select) and
|
- tokio: The Rust asynchronous runtime. Has single-threaded (select) and
|
||||||
multi-threaded variants.
|
multi-threaded variants.
|
||||||
|
@ -39,7 +38,7 @@ converting data from one format to another.
|
||||||
|
|
||||||
All of these go into `src/lib.rs`:
|
All of these go into `src/lib.rs`:
|
||||||
|
|
||||||
```
|
``` rust
|
||||||
async fn health_check() -> impl IntoResponse {
|
async fn health_check() -> impl IntoResponse {
|
||||||
(StatusCode::OK, ())
|
(StatusCode::OK, ())
|
||||||
}
|
}
|
||||||
|
@ -65,7 +64,7 @@ E>`](https://doc.rust-lang.org/std/result/), especially one with an error.</asid
|
||||||
We then define the routes that our server will recognize. This is
|
We then define the routes that our server will recognize. This is
|
||||||
straightforward and familiar territory:
|
straightforward and familiar territory:
|
||||||
|
|
||||||
```
|
``` rust
|
||||||
fn app() -> Router {
|
fn app() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(anon_greet))
|
.route("/", get(anon_greet))
|
||||||
|
@ -76,7 +75,7 @@ fn app() -> Router {
|
||||||
|
|
||||||
We then define a function to *run* the core server:
|
We then define a function to *run* the core server:
|
||||||
|
|
||||||
```
|
``` rust
|
||||||
pub async fn run() {
|
pub async fn run() {
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||||
tracing::info!("listening on {}", addr);
|
tracing::info!("listening on {}", addr);
|
||||||
|
@ -89,7 +88,7 @@ pub async fn run() {
|
||||||
|
|
||||||
And finally, in a file named `src/main.rs`, we instantiate the server:
|
And finally, in a file named `src/main.rs`, we instantiate the server:
|
||||||
|
|
||||||
```
|
``` rust
|
||||||
use ztp::run;
|
use ztp::run;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
@ -104,7 +103,7 @@ between the library and the CLI program.
|
||||||
In the project root's `Cargo.toml` file, the first three sections are needed to
|
In the project root's `Cargo.toml` file, the first three sections are needed to
|
||||||
define these relationships:
|
define these relationships:
|
||||||
|
|
||||||
```
|
``` toml
|
||||||
[package]
|
[package]
|
||||||
name = "ztp"
|
name = "ztp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
@ -127,13 +126,13 @@ project to have more than one package, called "crates," per project. </aside>
|
||||||
|
|
||||||
This project should now be runnable. In one window, type:
|
This project should now be runnable. In one window, type:
|
||||||
|
|
||||||
```
|
``` sh
|
||||||
$ cargo run
|
$ cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
And in another, type and see the replies:
|
And in another, type and see the replies:
|
||||||
|
|
||||||
```
|
``` sh
|
||||||
$ curl http://localhost:3000/
|
$ curl http://localhost:3000/
|
||||||
Hello, World!
|
Hello, World!
|
||||||
$ curl http://localhost:3000/Jim
|
$ curl http://localhost:3000/Jim
|
||||||
|
@ -159,7 +158,7 @@ exercise its functions. We'll use Tokio's `spawn` function to create a new
|
||||||
server, use hyper to request data from the server, and finally Rust's own native
|
server, use hyper to request data from the server, and finally Rust's own native
|
||||||
test asserts to check that we got what we expected.
|
test asserts to check that we got what we expected.
|
||||||
|
|
||||||
```
|
``` rust
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
+++
|
||||||
|
title = "Refactored Tests and Pre-Commits"
|
||||||
|
date = 2023-03-20T17:38:12Z
|
||||||
|
weight = 3
|
||||||
|
+++
|
||||||
|
|
||||||
|
## Chapter 3.7 (Sort-of): A brief diversion
|
||||||
|
|
||||||
|
Re-reading the text, I made a number of changes. The first is that, while it is
|
||||||
|
nice that Rust allows us to have unit tests in the file whose functionality
|
||||||
|
we're testing, it's also nice to have the tests somewhere separate, and to have
|
||||||
|
the tests be a little more modular.
|
||||||
|
|
||||||
|
In the `./tests` folder, you can now see the same `health_check` test as the
|
||||||
|
original, but in an isolated and cleaned-up form. Most importantly, the server
|
||||||
|
startup code is now in its own function, with a correct return type that
|
||||||
|
includes a handle to the spawned thread and the address on which that server is
|
||||||
|
listening; tests can be run in parallel on many different ports and a lot of
|
||||||
|
code duplication is eliminated.
|
||||||
|
|
||||||
|
``` rust
|
||||||
|
type NullHandle = JoinHandle<()>;
|
||||||
|
|
||||||
|
async fn spawn_server() -> (SocketAddr, NullHandle) {
|
||||||
|
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())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
(addr, handle)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is also possible now to add new tests in a straightforward manner. The
|
||||||
|
Hyper API is not that much different from the Actix request API, and the Axum
|
||||||
|
extractors seem to be straightforward. I suspect that what I'm looking at here
|
||||||
|
with the handle is the idea that, when it goes out of scope, it calls a d
|
||||||
|
|
||||||
|
## Adding some checks
|
||||||
|
|
||||||
|
In the introduction I said I was going to be neglecting CI/CD, since I'm a solo
|
||||||
|
developer. That's true, but I do like my guardrails. I like not being able to
|
||||||
|
commit garbage to the repository. So I'm going to add some checks, using
|
||||||
|
[Pre-Commit](https://pre-commit.com/).
|
||||||
|
|
||||||
|
Pre-Commit is a Python program, so we'll start by installing it. I'm using a
|
||||||
|
local Python environment kickstarted with
|
||||||
|
[Pyenv](https://github.com/pyenv/pyenv).
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
$ pip install pre-commit
|
||||||
|
```
|
||||||
|
|
||||||
|
And inside your project, in the project root, you hook it up with the following commands:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
$ pre-commit install
|
||||||
|
$ pre-commit sample-config > .pre-commit-config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
I'm going with the default from the rust pre-commit collection, so my
|
||||||
|
`.pre-commit-config.yaml` file looks like this:
|
||||||
|
|
||||||
|
``` yaml
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v3.1.0
|
||||||
|
hooks:
|
||||||
|
- id: check-byte-order-marker
|
||||||
|
- id: check-case-conflict
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: check-symlinks
|
||||||
|
- id: check-yaml
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: mixed-line-ending
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit
|
||||||
|
rev: v2.5.1
|
||||||
|
hooks:
|
||||||
|
- id: validate_manifest
|
||||||
|
- repo: https://github.com/doublify/pre-commit-rust
|
||||||
|
rev: master
|
||||||
|
hooks:
|
||||||
|
- id: fmt
|
||||||
|
- id: cargo-check
|
||||||
|
- id: clippy
|
||||||
|
```
|
||||||
|
|
||||||
|
... and with that, every time I try to commit my code, it will not let me until
|
||||||
|
these tests pass. And I *like* that level of discipline. This is low-level
|
||||||
|
validation; it won't catch if I put addition where I meant subtraction, or if I
|
||||||
|
have a comparison going in the wrong direction, but at least the basics are
|
||||||
|
handled and, more importantly, the formatting and styling is consistent
|
||||||
|
throughout all of my code.
|
|
@ -17,4 +17,3 @@ book. Those changes include:
|
||||||
- Neglecting CI/CD, since I'm a sole developer
|
- Neglecting CI/CD, since I'm a sole developer
|
||||||
- Developing entirely in a laptop environment
|
- Developing entirely in a laptop environment
|
||||||
- Using Axum instead of Actix-Web for the server framework
|
- Using Axum instead of Actix-Web for the server framework
|
||||||
|
|
||||||
|
|
10
src/lib.rs
10
src/lib.rs
|
@ -1,10 +1,8 @@
|
||||||
use axum::{extract::Path, http::StatusCode, response::IntoResponse, routing::get, Router};
|
use axum::{extract::Path, http::StatusCode, response::IntoResponse, routing::get, Router};
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
async fn anon_greet() -> &'static str {
|
mod user;
|
||||||
"Hello World!\n"
|
use user::index;
|
||||||
}
|
|
||||||
|
|
||||||
async fn greet(Path(name): Path<String>) -> impl IntoResponse {
|
async fn greet(Path(name): Path<String>) -> impl IntoResponse {
|
||||||
let greeting = String::from("He's dead, ") + name.as_str();
|
let greeting = String::from("He's dead, ") + name.as_str();
|
||||||
|
@ -16,9 +14,9 @@ async fn health_check() -> impl IntoResponse {
|
||||||
(StatusCode::OK, ())
|
(StatusCode::OK, ())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn app() -> Router {
|
pub fn app() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(anon_greet))
|
.route("/", get(index))
|
||||||
.route("/:name", get(greet))
|
.route("/:name", get(greet))
|
||||||
.route("/health_check", get(health_check))
|
.route("/health_check", get(health_check))
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
use axum::{http::StatusCode, response::IntoResponse, Form};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct FormData {
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn index(payload: Option<Form<FormData>>) -> impl IntoResponse {
|
||||||
|
let username = payload.map_or("World !".to_string(), move |index| -> String {
|
||||||
|
String::from(&(index.username))
|
||||||
|
});
|
||||||
|
(StatusCode::OK, format!("Hello, {}", &username))
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
};
|
||||||
|
use std::net::{SocketAddr, TcpListener};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use ztp::*;
|
||||||
|
|
||||||
|
type NullHandle = JoinHandle<()>;
|
||||||
|
|
||||||
|
async fn spawn_server() -> (SocketAddr, NullHandle) {
|
||||||
|
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())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
(addr, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_real_deal() {
|
||||||
|
let (addr, _server_handle) = spawn_server().await;
|
||||||
|
|
||||||
|
let response = hyper::Client::new()
|
||||||
|
.request(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("http://{}/", addr))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
|
||||||
|
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")
|
||||||
|
.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;
|
||||||
|
|
||||||
|
let test_cases = vec![
|
||||||
|
("name=le%20guin", "missing the email"),
|
||||||
|
("email=ursula_le_guin%40gmail.com", "missing the name"),
|
||||||
|
("", "missing both name and email"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (invalid_body, error_message) in test_cases {
|
||||||
|
let response = hyper::Client::new()
|
||||||
|
.request(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(format!("http://{}/subscriptions", addr))
|
||||||
|
.body(Body::from(invalid_body))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request.");
|
||||||
|
|
||||||
|
// TODO This should be 400 "Bad Request"
|
||||||
|
assert_eq!(
|
||||||
|
405,
|
||||||
|
response.status().as_u16(),
|
||||||
|
// Additional customised error message on test failure
|
||||||
|
"The API did not fail with 400 Bad Request when the payload was {}.",
|
||||||
|
error_message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue