From e5661c44f5233832443bdb8169dc1109919cae50 Mon Sep 17 00:00:00 2001 From: tison Date: Sun, 17 Mar 2024 12:12:21 +0800 Subject: [PATCH 1/4] build: add rust-toolchain.toml Signed-off-by: tison --- Cargo.toml | 6 +++--- examples/hello-world/Cargo.toml | 4 ++-- rust-toolchain.toml | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 rust-toolchain.toml diff --git a/Cargo.toml b/Cargo.toml index b6cfe54..abcf10d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "axum-test-helper" -version = "0.3.0" +version = "0.4.0" edition = "2021" categories = ["development-tools::testing"] description = "Extra utilities for axum" @@ -11,7 +11,7 @@ readme = "README.md" repository = "https://github.com/cloudwalk/axum-test-helper" [dependencies] -axum = "0.6" +axum = "0.7" reqwest = { version = "0.11", features = ["json", "stream", "multipart", "rustls-tls"], default-features = false } http = "0.2" http-body = "0.4" @@ -20,7 +20,7 @@ tower = "0.4.13" tower-service = "0.3" serde = "1.0" tokio = "1" -hyper = "0.14" +hyper = "1.0" [dev-dependencies] serde = { version = "1", features = ["serde_derive"] } diff --git a/examples/hello-world/Cargo.toml b/examples/hello-world/Cargo.toml index 98618fe..3bbafba 100644 --- a/examples/hello-world/Cargo.toml +++ b/examples/hello-world/Cargo.toml @@ -5,6 +5,6 @@ edition = "2021" publish = false [dependencies] -axum = { version = "0.6.*" } -axum-test-helper = { version = "0.*" } +axum = { version = "0.7" } +axum-test-helper = { version = "0.4" } tokio = { version = "1.0", features = ["full"] } diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..3fe7418 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy", "rust-analyzer"] From 52a6fe0c588c9c5acbeea33bc0386c0163a5c40c Mon Sep 17 00:00:00 2001 From: tison Date: Sun, 17 Mar 2024 16:21:53 +0800 Subject: [PATCH 2/4] feat: support axum 0.7 Signed-off-by: tison --- .github/workflows/ci.yml | 62 +++++++++++++++++++++++++++++++++++ Cargo.toml | 8 ++--- README.md | 4 +-- src/lib.rs | 71 +++++++++++++++++++++++++++------------- 4 files changed, 117 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c4500e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +# Concurrency strategy: +# github.workflow: distinguish this workflow from others +# github.event_name: distinguish `push` event from `pull_request` event +# github.event.number: set to the number of the pull request if `pull_request` event +# github.run_id: otherwise, it's a `push` event, only cancel if we rerun the workflow +# +# Reference: +# https://docs.github.com/en/actions/using-jobs/using-concurrency +# https://docs.github.com/en/actions/learn-github-actions/contexts#github-context +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.number || github.run_id }} + cancel-in-progress: true + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - name: Check Clippy + run: cargo clippy --tests --all-features --all-targets --workspace -- -D warnings + - name: Check format + run: cargo fmt --all --check + + test: + name: Build and test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - run: cargo build --workspace --all-features --tests --examples --benches + - name: Run tests + run: cargo test --workspace -- --nocapture + - name: Run benches + run: cargo bench --workspace -- --test + + required: + name: Required + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - check + - failpoints + - test + steps: + - name: Guardian + run: | + if [[ ! ( \ + "${{ needs.check.result }}" == "success" \ + && "${{ needs.test.result }}" == "success" \ + ) ]]; then + echo "Required jobs haven't been completed successfully." + exit -1 + fi diff --git a/Cargo.toml b/Cargo.toml index abcf10d..e758003 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,10 +13,10 @@ repository = "https://github.com/cloudwalk/axum-test-helper" [dependencies] axum = "0.7" reqwest = { version = "0.11", features = ["json", "stream", "multipart", "rustls-tls"], default-features = false } -http = "0.2" -http-body = "0.4" -bytes = "1.4.0" -tower = "0.4.13" +http = "1.1" +http-body = "1.0" +bytes = "1.4" +tower = "0.4" tower-service = "0.3" serde = "1.0" tokio = "1" diff --git a/README.md b/README.md index 1b3cc1c..5129d05 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ More information about this crate can be found in the [crate documentation][docs ## High level features -- Provide an easy to use interface +- Provide an easy-to-use interface - Start a server in a different port for each call - Deal with JSON, text and files response/requests @@ -16,7 +16,7 @@ Add this crate as a dev-dependency: ``` [dev-dependencies] -axum-test-helper = "0.*" # alternatively specify the version as "0.3.0" +axum-test-helper = "0.4" ``` Use the TestClient on your own Router: diff --git a/src/lib.rs b/src/lib.rs index ee48043..8dbd58a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,15 +35,17 @@ //! runtime.block_on(async_block); //! } -use axum::{body::HttpBody, BoxError}; +use axum::serve; use bytes::Bytes; use http::{ header::{HeaderName, HeaderValue}, - Request, StatusCode, + StatusCode, }; -use hyper::{Body, Server}; -use std::convert::TryFrom; +use std::convert::{Infallible, TryFrom}; use std::net::{SocketAddr, TcpListener}; +use std::str::FromStr; +use axum::extract::Request; +use axum::response::Response; use tower::make::Shared; use tower_service::Service; @@ -52,25 +54,36 @@ pub struct TestClient { addr: SocketAddr, } +pub(crate) fn spawn_service(svc: S) -> SocketAddr + where + S: Service + Clone + Send + 'static, + S::Future: Send, +{ + let std_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + std_listener.set_nonblocking(true).unwrap(); + let listener = tokio::net::TcpListener::from_std(std_listener).unwrap(); + + let addr = listener.local_addr().unwrap(); + + #[cfg(feature = "withtrace")] + println!("Listening on {addr}"); + + tokio::spawn(async move { + serve(listener, Shared::new(svc)) + .await + .expect("server error") + }); + + addr +} + impl TestClient { - pub fn new(svc: S) -> Self + pub fn new(svc: S) -> Self where - S: Service, Response = http::Response> + Clone + Send + 'static, - ResBody: HttpBody + Send + 'static, - ResBody::Data: Send, - ResBody::Error: Into, + S: Service + Clone + Send + 'static, S::Future: Send, - S::Error: Into, { - let listener = TcpListener::bind("127.0.0.1:0").expect("Could not bind ephemeral socket"); - let addr = listener.local_addr().unwrap(); - #[cfg(feature = "withtrace")] - println!("Listening on {}", addr); - - tokio::spawn(async move { - let server = Server::from_tcp(listener).unwrap().serve(Shared::new(svc)); - server.await.expect("server error"); - }); + let addr = spawn_service(svc); #[cfg(feature = "cookies")] let client = reqwest::Client::builder() @@ -169,6 +182,13 @@ impl RequestBuilder { HeaderValue: TryFrom, >::Error: Into, { + // reqwest still uses http 0.2 + let key: HeaderName = key.try_into().map_err(Into::into).unwrap(); + let key = reqwest::header::HeaderName::from_bytes(key.as_ref()).unwrap(); + + let value: HeaderValue = value.try_into().map_err(Into::into).unwrap(); + let value = reqwest::header::HeaderValue::from_bytes(value.as_bytes()).unwrap(); + self.builder = self.builder.header(key, value); self } @@ -206,11 +226,18 @@ impl TestResponse { } pub fn status(&self) -> StatusCode { - self.response.status() + StatusCode::from_u16(self.response.status().as_u16()).unwrap() } - pub fn headers(&self) -> &http::HeaderMap { - self.response.headers() + pub fn headers(&self) -> http::HeaderMap { + // reqwest still uses http 0.2 so have to convert into http 1.0 + let mut headers = http::HeaderMap::new(); + for (key, value) in self.response.headers() { + let key = HeaderName::from_str(key.as_str()).unwrap(); + let value = HeaderValue::from_bytes(value.as_bytes()).unwrap(); + headers.insert(key, value); + } + headers } pub async fn chunk(&mut self) -> Option { From 853063a97cff963b878c09be89a3a33e1fae6146 Mon Sep 17 00:00:00 2001 From: tison Date: Sun, 17 Mar 2024 16:26:15 +0800 Subject: [PATCH 3/4] fix: fixup examples Signed-off-by: tison --- .github/workflows/ci.yml | 17 ++++++++++++++--- examples/hello-world/Cargo.toml | 2 +- examples/hello-world/src/main.rs | 8 ++++---- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c4500e..73757f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,18 @@ jobs: - run: cargo build --workspace --all-features --tests --examples --benches - name: Run tests run: cargo test --workspace -- --nocapture - - name: Run benches - run: cargo bench --workspace -- --test + + examples: + name: Validate examples + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - run: cargo build --workspace --all-features --tests --examples --benches + working-directory: examples/hello-world + - name: Run tests + run: cargo test --workspace -- --nocapture + working-directory: examples/hello-world required: name: Required @@ -48,7 +58,7 @@ jobs: if: ${{ always() }} needs: - check - - failpoints + - examples - test steps: - name: Guardian @@ -56,6 +66,7 @@ jobs: if [[ ! ( \ "${{ needs.check.result }}" == "success" \ && "${{ needs.test.result }}" == "success" \ + && "${{ needs.examples.result }}" == "success" \ ) ]]; then echo "Required jobs haven't been completed successfully." exit -1 diff --git a/examples/hello-world/Cargo.toml b/examples/hello-world/Cargo.toml index 3bbafba..ef281c2 100644 --- a/examples/hello-world/Cargo.toml +++ b/examples/hello-world/Cargo.toml @@ -6,5 +6,5 @@ publish = false [dependencies] axum = { version = "0.7" } -axum-test-helper = { version = "0.4" } +axum-test-helper = { path = "../.." } tokio = { version = "1.0", features = ["full"] } diff --git a/examples/hello-world/src/main.rs b/examples/hello-world/src/main.rs index 0ed283a..0cdea77 100644 --- a/examples/hello-world/src/main.rs +++ b/examples/hello-world/src/main.rs @@ -6,6 +6,7 @@ use axum::{response::Html, routing::get, Router}; use std::net::SocketAddr; +use tokio::net::TcpListener; #[tokio::main] async fn main() { @@ -15,10 +16,9 @@ async fn main() { // run it let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); println!("listening on {}", addr); - axum::Server::bind(&addr) - .serve(router.into_make_service()) - .await - .unwrap(); + + let listener = TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, router.into_make_service()).await.unwrap(); } fn router() -> Router { From f55e7d5d074cd296904ae4fc02fe24dc136c1d15 Mon Sep 17 00:00:00 2001 From: tison Date: Sun, 17 Mar 2024 16:36:35 +0800 Subject: [PATCH 4/4] chore: style and clippy Signed-off-by: tison --- src/lib.rs | 58 +++++++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8dbd58a..f092479 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,35 +6,37 @@ //! - `withouttrace` - Disables tracing for the test client. //! //! ## Example +//! //! ```rust //! use axum::Router; //! use axum::http::StatusCode; //! use axum::routing::get; //! use axum_test_helper::TestClient; //! -//! fn main() { -//! let async_block = async { -//! // you can replace this Router with your own app -//! let app = Router::new().route("/", get(|| async {})); +//! let async_block = async { +//! // you can replace this Router with your own app +//! let app = Router::new().route("/", get(|| async {})); //! -//! // initiate the TestClient with the previous declared Router -//! let client = TestClient::new(app); +//! // initiate the TestClient with the previous declared Router +//! let client = TestClient::new(app); //! -//! let res = client.get("/").send().await; -//! assert_eq!(res.status(), StatusCode::OK); -//! }; +//! let res = client.get("/").send().await; +//! assert_eq!(res.status(), StatusCode::OK); +//! }; //! -//! // Create a runtime for executing the async block. This runtime is local -//! // to the main function and does not require any global setup. -//! let runtime = tokio::runtime::Builder::new_current_thread() -//! .enable_all() -//! .build() -//! .unwrap(); +//! // Create a runtime for executing the async block. This runtime is local +//! // to the main function and does not require any global setup. +//! let runtime = tokio::runtime::Builder::new_current_thread() +//! .enable_all() +//! .build() +//! .unwrap(); //! -//! // Use the local runtime to block on the async block. -//! runtime.block_on(async_block); -//! } +//! // Use the local runtime to block on the async block. +//! runtime.block_on(async_block); +//! ``` +use axum::extract::Request; +use axum::response::Response; use axum::serve; use bytes::Bytes; use http::{ @@ -44,8 +46,6 @@ use http::{ use std::convert::{Infallible, TryFrom}; use std::net::{SocketAddr, TcpListener}; use std::str::FromStr; -use axum::extract::Request; -use axum::response::Response; use tower::make::Shared; use tower_service::Service; @@ -55,9 +55,9 @@ pub struct TestClient { } pub(crate) fn spawn_service(svc: S) -> SocketAddr - where - S: Service + Clone + Send + 'static, - S::Future: Send, +where + S: Service + Clone + Send + 'static, + S::Future: Send, { let std_listener = TcpListener::bind("127.0.0.1:0").unwrap(); std_listener.set_nonblocking(true).unwrap(); @@ -264,9 +264,9 @@ impl AsRef for TestResponse { #[cfg(test)] mod tests { use axum::response::Html; - use serde::{Deserialize, Serialize}; - use axum::{routing::get, routing::post, Router, Json}; + use axum::{routing::get, routing::post, Json, Router}; use http::StatusCode; + use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct FooForm { @@ -303,13 +303,17 @@ mod tests { #[tokio::test] async fn test_post_request_with_json() { - let app = Router::new().route("/", post(|json_value: Json| async {json_value})); + let app = Router::new().route( + "/", + post(|json_value: Json| async { json_value }), + ); let client = super::TestClient::new(app); let payload = TestPayload { name: "Alice".to_owned(), age: 30, }; - let res = client.post("/") + let res = client + .post("/") .header("Content-Type", "application/json") .json(&payload) .send()