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:
parent
8b1fbec3b2
commit
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
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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
|
||||
- Developing entirely in a laptop environment
|
||||
- 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 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))
|
||||
}
|
||||
|
|
|
@ -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