Pre-commit checks and test refactorings.

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

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.
This commit is contained in:
Elf M. Sternberg 2023-03-21 17:52:44 -07:00
parent 8b1fbec3b2
commit 89fb8188b7
12 changed files with 302 additions and 25 deletions

3
.markdownlint.yaml Normal file
View File

@ -0,0 +1,3 @@
---
commands-show-output: false
no-inline-html: false

29
.pre-commit-config.yaml Normal file
View File

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

23
.pre-commit-hooks.yaml Normal file
View File

@ -0,0 +1,23 @@
---
- id: fmt
name: fmt
description: Format files with cargo fmt.
entry: cargo fmt
language: system
types: [rust]
args: ["--"]
- id: cargo-check
name: cargo check
description: Check the package for errors.
entry: cargo check
language: system
types: [rust]
pass_filenames: false
- id: clippy
name: clippy
description: Lint rust sources
entry: cargo clippy
language: system
args: ["--", "-D", "warnings"]
types: [rust]
pass_filenames: false

15
Cargo.lock generated
View File

@ -452,6 +452,20 @@ name = "serde"
version = "1.0.158"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "serde_json"
@ -826,6 +840,7 @@ version = "0.1.0"
dependencies = [
"axum",
"hyper",
"serde",
"tokio",
"tower",
"tracing",

View File

@ -13,8 +13,13 @@ name = "ztp"
[dependencies]
axum = "0.6.11"
hyper = { version = "0.14.25", features = ["full"] }
serde = { version = "1.0.158", features = ["derive"] }
tokio = { version = "1.26.0", features = ["full"] }
tower = "0.4.13"
tracing = "0.1.35"
tracing-subscriber = "0.3.14"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1

View File

@ -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
local account:
```
``` sh
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
Once installed, you can install Rust itself:
```
``` sh
$ rustup install toolchain stable
```
You should now have Rust compiler and the Rust build and packaging tool, known
as Cargo:
```
``` sh
$ rustc --version
rustc 1.68.0 (2c8cc3432 2023-03-06)
$ cargo --version
@ -34,8 +34,8 @@ cargo 1.68.0 (115f34552 2023-02-26)
I also installed the following tools:
```
$ rustup component add clippy rust-src rust-docs
``` sh
$ rustup component add clippy rust-src rust-docs
$ 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
Protocol) for Rust, giving you code completion, on-the-fly error definition,
and other luxuries.

View File

@ -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
commands installed the tools I'll be using:
```
``` sh
cargo add --features tokio/full --features hyper/full tokio hyper \
axum tracing tracing-subscriber
```
- axum: The web server framework for Tokio.
- tokio: The Rust asynchronous runtime. Has single-threaded (select) and
multi-threaded variants.
@ -39,7 +38,7 @@ converting data from one format to another.
All of these go into `src/lib.rs`:
```
``` rust
async fn health_check() -> impl IntoResponse {
(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
straightforward and familiar territory:
```
``` rust
fn app() -> Router {
Router::new()
.route("/", get(anon_greet))
@ -76,7 +75,7 @@ fn app() -> Router {
We then define a function to *run* the core server:
```
``` rust
pub async fn run() {
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
tracing::info!("listening on {}", addr);
@ -85,11 +84,11 @@ pub async fn run() {
.await
.unwrap()
}
```
```
And finally, in a file named `src/main.rs`, we instantiate the server:
```
``` rust
use ztp::run;
#[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
define these relationships:
```
``` toml
[package]
name = "ztp"
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:
```
``` sh
$ cargo run
```
And in another, type and see the replies:
```
``` sh
$ curl http://localhost:3000/
Hello, World!
$ 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
test asserts to check that we got what we expected.
```
``` rust
#[cfg(test)]
mod tests {
use super::*;

View File

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

View File

@ -17,4 +17,3 @@ book. Those changes include:
- Neglecting CI/CD, since I'm a sole developer
- Developing entirely in a laptop environment
- Using Axum instead of Actix-Web for the server framework

View File

@ -1,10 +1,8 @@
use axum::{extract::Path, http::StatusCode, response::IntoResponse, routing::get, Router};
use std::net::SocketAddr;
async fn anon_greet() -> &'static str {
"Hello World!\n"
}
mod user;
use user::index;
async fn greet(Path(name): Path<String>) -> impl IntoResponse {
let greeting = String::from("He's dead, ") + name.as_str();
@ -16,9 +14,9 @@ async fn health_check() -> impl IntoResponse {
(StatusCode::OK, ())
}
fn app() -> Router {
pub fn app() -> Router {
Router::new()
.route("/", get(anon_greet))
.route("/", get(index))
.route("/:name", get(greet))
.route("/health_check", get(health_check))
}

13
src/user.rs Normal file
View File

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

95
tests/health_check.rs Normal file
View File

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