1. Cài đặt môi trường
Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Kiểm tra phiên bản
rustc --version # cargo là package manager của Rust (tương tự npm của Node.js)
cargo --version
cargo-watch
Hot reload tương tự nodemon của Node.js, rất tiện lợi trong quá trình phát triển.
cargo install cargo-watch
Linter
rustup component add clippy
Format code
rustup component add rustfmt
2. Mục tiêu sản phẩm
Chúng ta sẽ xây dựng một dịch vụ gửi email bằng Rust với các chức năng cơ bản sau đây:
- Người dùng có thể ấn theo dõi tác giả yêu thích.
- Mỗi khi người viết đăng bài mới, các email thông báo sẽ được gửi đến những người theo dõi của tác giả đó
Được rồi, chúng ta cùng bắt tay vào làm thôi
3. Sign Up A New Subscriber
Chúng ta có user story như sau:
Là một người dùng
Tôi muốn theo dõi các bài viết
Do đó tôi có thể nhận được email thông báo khi có bài viết mới được đăng lên trang blog
Vậy chúng ta sẽ cùng đi thiết kế 1 API /subscriptions
nhận email nhập vào từ người dùng để thực hiện tính năng trên.
Những việc cần làm
- Chọn framework để viết API
- Viết test
- Tương tác với database
Chọn framework
Hiện tại, số lượng các web framework của Rust cũng rất đa dạng (actix-web , axum , poem , tide , rocket , ..). Chúng ta sẽ sử dụng actix-web, một framework có tuổi đời lâu nhất cùng với cộng đồng hỗ trợ đông đảo sẽ giúp ích cho việc tìm hiểu và xử lý các lỗi không mong muốn xảy ra.
3.1 API healthcheck
Khởi tạo dự án
cargo new zero2prod
Cài đặt thư viện
Thêm định nghĩa về các thư viện sẽ dùng vào file Cargo.toml
#! Cargo.toml
# [...] [dependencies]
actix-web = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Hoặc cài đặt qua lệnh cargo add
# framework actix-web
cargo add actix-web@4 # Thư viện giúp xử lý các tác vụ bất đồng bộ với Rust (asynchronous runtime)
cargo add tokio@1 --features macros,rt-multi-thread
Hello world
// main.rs
use actix_web::{web, App, HttpRequest, HttpServer, Responder}; async fn greet(req: HttpRequest) -> impl Responder { let name = req.match_info().get("name").unwrap_or("World"); format!("Hello {}!", &name)
} #[tokio::main]
async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .route("/", web::get().to(greet)) .route("/{name}", web::get().to(greet)) }) .bind("127.0.0.1:8000")? .run() .await
}
Chạy server:
cargo run
# hoặc hot reload với cargo-watch
cargo watch -x run
Test API:
curl http://localhost:8000
Server đã trả về "Hello World!" Sau đây chúng ta sẽ bóc tách để xem ý nghĩa đoạn code như thế nào.
Các thành phần của 1 actix-web http server
Chúng ta sẽ phân tích đoạn code dưới đây:
// src/main.rs
// [...] #[tokio::main]
async fn main() -> std::io::Result<()> { HttpServer::new(|| App::new() .route("/", web::get().to(greet)) .route("/{name}", web::get().to(greet)) }) .bind("127.0.0.1:8000")? .run() .await
}
Server - HttpServer
struct HttpServer
có nhiệm vụ xử lý tất cả các tác vụ ở tầng giao thức như:
- Định nghĩa cổng http được mở
- Cấu hình TLS/HTTPS
- Số lượng kết nối tối đa trong 1 thời điểm
- ...
Application - App
struct App
là nơi sẽ định nghĩa các routes, middlewares và xử lý các request .
Endpoint - Route
route
method giúp định nghĩa các đầu API và logic xử lý chúng.
Hiện tại ta đang định nghĩa 2 đầu API
- GET
/
: trả vềHello, world!
- GET
/{name}
: trả vềHello, {name}!
, trong đó name do người dùng truyền vào
Request từ client đến sẽ được xử lý và trả về bởi hàm greet(req: HttpRequest)
Runtime - tokio
Ngày trên định nghĩa hàm main
, có 1 dòng liên quan đến package tokio là #[tokio::main]
. Giờ hãy thử bỏ nó và chạy lại server xem sao.
main function is not allowed to be async
Thư viện chuẩn của Rust không hỗ trợ hàm main
chạy bất đồng bộ. Do đó, chúng ta cần cài thêm thư viện như tokio hay async-std để giúp main
có thể xử lý bất đồng bộ.
Triển khai logic api healthcheck
Logic đơn giản là gọi đến api /health_check
thì server sẽ trả về 200 OK.
//! src/main.rs use actix_web::{web, App, HttpResponse, HttpServer, Responder}; async fn health_check() -> impl Responder { HttpResponse::Ok() // status 200
} #[tokio::main]
async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .route("/health_check", web::get().to(health_check)) }) .bind("127.0.0.1:8000")? .run() .await
}
3.2 Integration Test
Viết test như thế nào ?
Với Rust, chúng ta có thể viết test theo 2 cách phổ biến sau:
- Viết trong cùng file luôn với đoạn code muốn test
// code muốn test (logic API, middlewares ...) #[cfg(test)]
mod tests { use super :: *; // code tests
}
Cách này chúng ta có thể dễ dàng gọi và đến các hàm, module muốn test. Tuy nhiên, với số lượng testcase nhiều sẽ file phình to ra với rất nhiều dòng.
- Viết test riêng nằm trong folder
tests
Chỉ có thể gọi đến các hàm, module public của source code. Ngược lại, so với cách 1 thì chúng ta có thể chia thành nhiều file test, code sẽ trông gọn gàng hơn.
src/
tests/
Cargo.toml
=> Chúng ta sẽ viết test theo cách 2.
// Tạo folder tests
mkdir -p tests
Cấu trúc lại thư mục dự án
Ta sẽ module hóa lại thành 2 file là lib.rs
và main.rs
lib.rs
sẽ chứa tất cả logicmain.rs
sẽ chỉ còn là một điểm đầu vào (entrypoint) khi khởi động app
//! lib.rs
use actix_web::{web, App, HttpResponse, HttpServer}; async fn health_check() -> HttpResponse { HttpResponse::Ok ().finish()
} // định nghĩa hàm run với từ khóa `pub` (public)
pub async fn run() -> std::io::Result<()> { HttpServer:: new(|| { App:: new() .route("/health_check", web::get().to(health_check)) }) .bind("127.0.0.1:8000")? .run() .await
}
//! main.rs // import hàm run
use zero2prod::run; #[tokio::main]
async fn main() -> std::io::Result<()> { run().await
}
Hàm run
bắt buộc phải định nghĩa với từ khóa pub để có thể import từ file khác.
=> Báo lỗi nếu không định nghĩa run là public
Ngoài ra, chúng ta cũng cần bổ sung file Cargo.toml
như sau:
# ... [lib]
path = "src/lib.rs" [[bin]]
path = "src/main.rs"
name = "zero2prod" # ...
Testcase đầu tiên
Cài đặt package reqwest (khá giống axios ) dùng để gửi Http request.
cargo add reqwest
// tests/health_check.rs
use zero2prod::run; #[tokio::test]
async fn health_check_works() { spawn_app().await.expect("Failed to spawn our app."); let client = reqwest::Client::new(); let response = client.get("http://127.0.0.1:8000/health_check").send().await.expect("Failed to execute request."); // test trả về status 200 assert!(response.status().is_success()); // test trả về body length = 0 assert_eq!(Some(0), response.content_length());
} async fn spawn_app() -> std::io::Result<()> { run().await
}
Chạy test với cargo test
Chúng ta gặp tình trạng test chạy vô hạn và không dừng lại. Nguyên do hàm spawn_app()
gọi run()
trong khi đó nó cũng đang await để lắng nghe cổng
=> Chúng ta cần sửa lại logic await ở hàm run()
cùng các file liên quan.
//! src/lib.rs
use actix_web::dev::Server;
use actix_web::{web, App, HttpResponse, HttpServer}; async fn health_check() -> HttpResponse { HttpResponse::Ok().finish()
} // return Server thay vì std::io:Result như cũ
// định nghĩa hàm run cũng bỏ từ khóa async
pub fn run() -> Result<Server, std::io::Error> { let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check))) .bind("127.0.0.1:8000")? .run(); // bỏ await Ok(server)
}
//! tests/health_check.rs use zero2prod::run; #[tokio::test]
async fn health_check_works() { // bỏ await, expect là spawn_app không còn là hàm bất đồng bộ spawn_app(); let client = reqwest::Client::new(); let response = client.get("http://127.0.0.1:8000/health_check") .send() .await .expect("Failed to execute request."); assert!(response.status().is_success()); assert_eq!(Some(0), response.content_length());
} fn spawn_app() { let server = run().expect("Failed to bind address"); // Khởi động server chạy nền let _ = tokio::spawn(server);
}
chạy test => passed
Cải thiện code
Hàm spawn_app()
khi gọi gọi sẽ sử dụng cổng 8000 để hoạt động. Lỗi xung đột sẽ xảy ra khi chạy test trong lúc app cũng đang được chạy.
Giải pháp xử lý ở đây là ta sẽ thêm tham số để cấu hình cổng được mở của server. Khi chạy test sẽ chọn một cổng ngẫu nhiên nào đó.
//! src/lib.rs // [...] // truyền thêm cổng muốn mở cho server thay vì fix cứng như trước
pub fn run(address: &str) -> Result<Server, std::io::Error> { let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check))) .bind(address)? .run(); Ok(server)
}
//! src/main.rs use zero2prod::run; #[tokio::main]
async fn main() -> std::io::Result<()> { run("127.0.0.1:8000")?.await
}
Chọn port 0
cho testcase
fn spawn_app() { let server = run("127.0.0.1:0").expect("Failed to bind address"); let _ = tokio::spawn(server);
}
Chạy test, tuy nhiên xuất hiện báo lỗi.
request vẫn được gọi tới cổng 8000 thay vì cổng 0 => lỗi. Phương pháp truyền tham cổng có vẻ không khả thi.
Sử dụng listener
Ngoài cách sử dụng hàm bind
và truyền vào cổng cần mở, chúng ta có thể sử dụng TcpListener. Với listener chúng ta có thể chủ động mở cổng trước thay vì truyền tham số vào hàm run như cách cũ.
//! src/lib.rs
use actix_web::dev::Server;
use actix_web::{web, App, HttpResponse, HttpServer};
use std::net::TcpListener; // [...] pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> { let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check))) .listen(listener)? .run(); Ok(server)
}
//! src/main.rs use std::net::TcpListener;
use zero2prod::run; #[tokio::main]
async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:8000")?; run(listener)?.await
}
//! tests/health_check.rs
use std::net::TcpListener;
use zero2prod::run; #[tokio::test]
async fn health_check_works() { let address = spawn_app(); let client = reqwest::Client::new(); let response = client // Use the returned application address .get(&format!("{}/health_check", &address)) .send() .await .expect("Failed to execute request."); // Assert assert!(response.status().is_success()); assert_eq!(Some(0), response.content_length());
} fn spawn_app() -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); let port = listener.local_addr().unwrap().port(); let server = run(listener).expect("Failed to bind address"); let _ = tokio::spawn(server); format!("http://127.0.0.1:{}", port)
}
chạy test => passed
3.3 Quay lại mục tiêu ban đầu
Vậy là qua các phần trên, chúng ta đã hoàn chỉnh 1 đầu API và testcase của nó. Nhưng đó cũng chỉ là 1 API health_check rất đơn giản nhằm mục đích làm quen với Rust và framework actix-web. Bây giờ, chúng ta cùng tập trung lại mục tiêu của dự án.
Là một người dùng
Tôi muốn theo dõi các bài viết
Do đó tôi có thể nhận được email thông báo khi có bài viết mới được đăng lên trang blog
Xử lý HTML form
Người dùng sẽ gửi tên và email lên server, ở đây chúng ta sẽ có 2 kịch bản chủ yếu
- Tên và email hợp lệ => trả về 200 OK
- Tên hoặc email không hợp lệ => trả về 400 BAD REQUEST
Triển khai testcase
Chúng ta sẽ biểu diễn cách TH qua testcase trước.
//! tests/health_check.rs
use std::net::TcpListener; // [...] #[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() { let app_address = spawn_app(); let client = reqwest::Client::new(); let body = "name=le%20g&email=le%40gmail.com"; let response = client .post(&format!("{}/subscriptions", &app_address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(body) .send() .await .expect("Failed to execute request."); assert_eq!(200, response.status().as_u16());
} #[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() { let app_address = spawn_app(); let client = reqwest::Client::new(); 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 = client .post(&format!("{}/subscriptions", &app_address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(invalid_body) .send() .await .expect("Failed to execute request."); assert_eq!( 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 {}.", error_message ); }
}
Do chưa có logic API nên kết quả khi chạy các testcase tạm thời sẽ fail.
Định nghĩa API /subscriptions
//! src/lib.rs // [...] // Đơn giản nhất chúng ta để hàm trả về status 200
async fn subscribe() -> HttpResponse { HttpResponse::Ok().finish()
} pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> { let server = HttpServer::new(|| { App::new() .route("/health_check", web::get().to(health_check)) // POST /subscriptions .route("/subscriptions", web::post().to(subscribe)) }) .listen(listener)? .run(); Ok(server)
}
Định nghĩa cấu trúc formData
Cài đặt thêm thư viện serde, giúp chuyển đổi các cấu trúc dữ liệu với nhau.
// [...] [dependencies]
serde = { version = "1", features = ["derive"]}
//! src/lib.rs // [...] // định nghĩa body data nhận từ client sẽ gồm email và name
#[derive(serde::Deserialize)]
struct FormData { email: String, name: String,
} async fn subscribe(_form: web::Form<FormData>) -> HttpResponse { HttpResponse::Ok().finish()
}
Database
Hệ quản trị cơ sở dữ liệu chúng ta sử dụng sẽ là PostgresSQL cùng với thư viện sqlx để kết nối và tương tác với PostgresSQL.
# Cargo.toml [dependencies]
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "macros", "postgres", "uuid", "chrono", "migrate"] }
# [...]
cấu hình database url và tạo bảng subscriptions.
# .env DATABASE_URL=postgres://postgress:password@127.0.0.1:5432/newsletter
CREATE TABLE subscriptions( id uuid NOT NULL, PRIMARY KEY (id), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, subscribed_at timestamptz NOT NULL
);
Cấu trúc lại thư mục dự án
Hiện tại tất cả logic API đều nằm tại file lib.rs
, chúng ta cần mô đun hóa, cấu trúc lại để mọi thứ trông sáng sủa hơn.
src/ configuration.rs lib.rs main.rs routes/ mod.rs health_check.rs subscriptions.rs startup.rs
tests/
Cargo.toml
configuration.yaml
- function
run
sẽ nằm ở filestartup.rs
- các API được chia thành 2 file
mod.rs
định nghĩa các module APIconfiguration.rs
xử các config về databaseconfiguration.yaml
chứa thông tin về database(port, address, database name)
//! src/routes/mod.rs mod health_check;
mod subscriptions; pub use health_check::*;
pub use subscriptions::*;
//! src/lib.rs pub mod configuration;
pub mod routes;
pub mod startup;
Cấu hình database
File configuration.rs
sẽ có nhiệm vụ đọc các thông tin liên quan đến database từ file configuration.yaml
.
cài đặt thêm thư viện config để làm việc với file yaml
cargo add config
// src/configuration.rs #[derive(serde::Deserialize)]
pub struct Settings { pub database: DatabaseSettings, pub application_port: u16,
} #[derive(serde::Deserialize)]
pub struct DatabaseSettings { pub username: String, pub password: String, pub port: u16, pub host: String, pub database_name: String,
} pub fn get_configuration() -> Result<Settings, config::ConfigError> { let settings = config::Config::builder() .add_source(config::File::new( "configuration.yaml", config::FileFormat::Yaml, )) .build()?; settings.try_deserialize::<Settings>()
}
# configuration.yaml application_port: 8000
database: host: "127.0.0.1" port: 5432 username: "" password: "" database_name: "newsletter"
//! src/main.rs use std::net::TcpListener;
use zero2prod::configuration::get_configuration;
use zero2prod::startup::run; #[tokio::main]
async fn main() -> std::io::Result<()> { // Báo lỗi nếu không thể đọc được từ file config let configuration = get_configuration().expect("Failed to read configuration."); // Bây giờ cổng được mở sẽ được quy định ở file config luôn let address = format!("127.0.0.1:{}", configuration.application_port); let listener = TcpListener::bind(address)?; run(listener)?.await
}
Kết nối với database
// src/configuration.rs
// [...] impl DatabaseSettings { pub fn connection_string(&self) -> String { format!( "postgres://{}:{}@{}:{}/{}", self.username, self.password, self.host, self.port, self.database_name ) }
}
Triển khai testcase với logic kết nối đến database (TH lý tưởng)
//! tests/health_check.rs use sqlx::{Connection, PgConnection};
use std::net::TcpListener;
use zero2prod::configuration::get_configuration;
use zero2prod::run; #[tokio::test]
async fn health_check_works() { let app_address = spawn_app(); let configuration = get_configuration().expect("Failed to read configuration"); let connection_string = configuration.database.connection_string(); // Báo lỗi nếu kết nối đến database thất bại let connection = PgConnection::connect(&connection_string) .await .expect("Failed to connect to Postgres."); let client = reqwest::Client::new(); let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; let response = client .post(&format!("{}/subscriptions", &app_address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(body) .send() .await .expect("Failed to execute request."); assert_eq!(200, response.status().as_u16());
} // [...]
Application State
Từ ban đầu đến hiện tại, ứng dụng hoàn toàn không lưu trữ trạng thái. Giờ đây, khi cần duy trì kết nối đến database khi chạy server => ta cần sửa đổi 1 chút hàm run
//! src/startup.rs use crate::routes::{health_check, subscribe};
use actix_web::dev::Server;
use actix_web::{web, App, HttpServer};
use sqlx::PgPool;
use std::net::TcpListener; pub fn run(listener: TcpListener, db_pool: PgPool) -> Result<Server, std::io::Error> { let db_pool = web::Data::new(db_pool); let server = HttpServer::new(move || { App::new() .route("/health_check", web::get().to(health_check)) .route("/subscriptions", web::post().to(subscribe)) .app_data(db_pool.clone()) }) .listen(listener)? .run(); Ok(server)
}
//! src/main.rs use sqlx::PgPool;
use std::net::TcpListener;
use zero2prod::configuration::get_configuration;
use zero2prod::startup::run; #[tokio::main]
async fn main() -> std::io::Result<()> { let configuration = get_configuration().expect("Failed to read configuration."); // Renamed! let connection_pool = PgPool::connect(&configuration.database.connection_string()) .await .expect("Failed to connect to Postgres."); let address = format!("127.0.0.1:{}", configuration.application_port); let listener = TcpListener::bind(address)?; run(listener, connection_pool)?.await
}
SQL insert
Triển khai logic lưu thông tin của người dùng vào database khi ấn theo dõi.
Cài đặt thêm thư viện tạo uuid và chrono quản lý thời gian
[dependencies]
# [...]
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4.22", default-features = false , features = ["clock"] }
// !src/routes/subscription.rs use actix_web::{web, HttpResponse};
use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid; #[derive(serde::Deserialize)]
pub struct FormData { email: String, name: String,
} pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse { match sqlx::query!( r#" INSERT INTO subscriptions (id, email, name, subscribed_at) VALUES ($1, $2, $3, $4) "#, Uuid::new_v4(), form.email, form.name, Utc::now() ) .execute(pool.as_ref()) .await { Ok(_) => HttpResponse::Ok().finish(), Err(e) => { println!("Failed to execute query: {}", e); HttpResponse::InternalServerError().finish() } }
}
Hoàn thiện testcase
// ! tests/health_check.rs use sqlx::{Connection, Executor, PgConnection, PgPool};
use std::net::TcpListener;
use uuid::Uuid;
use zero2prod::configuration::{get_configuration, DatabaseSettings};
use zero2prod::startup::run; pub struct TestApp { pub address: String, pub db_pool: PgPool,
} async fn spawn_app() -> TestApp { let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); // We retrieve the port assigned to us by the OS let port = listener.local_addr().unwrap().port(); let address = format!("http://127.0.0.1:{}", port); let mut configuration = get_configuration().expect("Failed to read configuration."); configuration.database.database_name = Uuid::new_v4().to_string(); let connection_pool = configure_database(&configuration.database).await; let server = run(listener, connection_pool.clone()).expect("Failed to bind address"); let _ = tokio::spawn(server); TestApp { address, db_pool: connection_pool, }
} pub async fn configure_database(config: &DatabaseSettings) -> PgPool { // Create database let mut connection = PgConnection::connect(&config.connection_string_without_db()) .await .expect("Failed to connect to Postgres"); connection .execute(&*format!(r#"CREATE DATABASE "{}";"#, config.database_name)) .await .expect("Failed to create database."); // Migrate database let connection_pool = PgPool::connect(&config.connection_string()) .await .expect("Failed to connect to Postgres."); sqlx::migrate!("./migrations") .run(&connection_pool) .await .expect("Failed to migrate the database"); connection_pool
} #[tokio::test]
async fn health_check_works() { // Arrange let app = spawn_app().await; let client = reqwest::Client::new(); // Act let response = client // Use the returned application address .get(&format!("{}/health_check", &app.address)) .send() .await .expect("Failed to execute request."); // Assert assert!(response.status().is_success()); assert_eq!(Some(0), response.content_length());
} #[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() { // Arrange let app = spawn_app().await; let client = reqwest::Client::new(); let body = "name=le%20guin&email=ursula_le_guin%40gmail.com"; // Act let response = client .post(&format!("{}/subscriptions", &app.address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(body) .send() .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(&app.db_pool) .await .expect("Failed to fetch saved subscription."); assert_eq!(saved.email, "ursula_le_guin@gmail.com"); assert_eq!(saved.name, "le guin");
} #[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() { // Arrange let app = spawn_app().await; let client = reqwest::Client::new(); 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 { // Act let response = client .post(&format!("{}/subscriptions", &app.address)) .header("Content-Type", "application/x-www-form-urlencoded") .body(invalid_body) .send() .await .expect("Failed to execute request."); // Assert assert_eq!( 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 {}.", error_message ); }
}