├── .github └── workflows │ └── checks.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── examples ├── user_server.rs └── user_server_test.rs ├── restest_macros ├── Cargo.toml ├── README.md └── src │ └── lib.rs ├── src ├── context.rs ├── lib.rs ├── request.rs └── url.rs └── tests ├── ok ├── json_pattern_array.rs ├── json_pattern_binding.rs └── json_pattern_number.rs └── setup.rs /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | test: 10 | name: Test 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions-rs/toolchain@v1 15 | with: 16 | profile: minimal 17 | toolchain: stable 18 | override: true 19 | - uses: actions-rs/cargo@v1 20 | with: 21 | command: test 22 | 23 | fmt: 24 | name: Rustfmt 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions-rs/toolchain@v1 29 | with: 30 | profile: minimal 31 | toolchain: stable 32 | override: true 33 | - run: rustup component add rustfmt 34 | - uses: actions-rs/cargo@v1 35 | with: 36 | command: fmt 37 | args: --all -- --check 38 | 39 | clippy: 40 | name: Clippy 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: actions-rs/toolchain@v1 45 | with: 46 | profile: minimal 47 | toolchain: stable 48 | override: true 49 | - run: rustup component add clippy 50 | - uses: actions-rs/cargo@v1 51 | with: 52 | command: clippy 53 | args: --all -- -D warnings 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /restest_macros/target 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and 9 | expression, 10 | level of experience, education, socio-economic status, nationality, personal 11 | appearance, race, religion, or sexual identity and orientation. 12 | 13 | ## Our Standards 14 | 15 | Examples of behavior that contributes to creating a positive environment 16 | include: 17 | 18 | - Using welcoming and inclusive language 19 | - Being respectful of differing viewpoints and experiences 20 | - Gracefully accepting constructive criticism 21 | - Focusing on what is best for the community 22 | - Showing empathy towards other community members 23 | 24 | Examples of unacceptable behavior by participants include: 25 | 26 | - The use of sexualized language or imagery and unwelcome sexual attention or 27 | advances 28 | - Trolling, insulting/derogatory comments, and personal or political attacks 29 | - Public or private harassment 30 | - Publishing others' private information, such as a physical or electronic 31 | address, without explicit permission 32 | - Other conduct which could reasonably be considered inappropriate in a 33 | professional setting 34 | 35 | ## Our Responsibilities 36 | 37 | Project maintainers are responsible for clarifying the standards of acceptable 38 | behavior and are expected to take appropriate and fair corrective action in 39 | response to any instances of unacceptable behavior. 40 | 41 | Project maintainers have the right and responsibility to remove, edit, or 42 | reject comments, commits, code, wiki edits, issues, and other contributions 43 | that are not aligned to this Code of Conduct, or to ban temporarily or 44 | permanently any contributor for other behaviors that they deem inappropriate, 45 | threatening, offensive, or harmful. 46 | 47 | ## Scope 48 | 49 | This Code of Conduct applies within all project spaces, and it also applies when 50 | an individual is representing the project or its community in public spaces. 51 | Examples of representing a project or community include using an official 52 | project e-mail address, posting via an official social media account, or acting 53 | as an appointed representative at an online or offline event. Representation of 54 | a project may be further defined and clarified by project maintainers. 55 | 56 | ## Enforcement 57 | 58 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 59 | reported by contacting the project team at jeremy.lempereur@gmail.com. All 60 | complaints will be reviewed and investigated and will result in a response that 61 | is deemed necessary and appropriate to the circumstances. The project team is 62 | obligated to maintain confidentiality with regard to the reporter of an 63 | incident. 64 | Further details of specific enforcement policies may be posted separately. 65 | 66 | Project maintainers who do not follow or enforce the Code of Conduct in good 67 | faith may face temporary or permanent repercussions as determined by other 68 | members of the project's leadership. 69 | 70 | ## Attribution 71 | 72 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 73 | version 1.4, 74 | available at 75 | https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 76 | 77 | [homepage]: https://www.contributor-covenant.org 78 | 79 | For answers to common questions about this code of conduct, see 80 | https://www.contributor-covenant.org/faq 81 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "restest" 3 | version = "0.1.0" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | description = "Black-box integration test for REST APIs in Rust." 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | dep_doc = "0.1" 12 | http = "0.2" 13 | reqwest = { version = "0.11", features = ["json"] } 14 | restest_macros = "0.1.0" 15 | serde = "1.0" 16 | anyhow = "1.0.58" 17 | 18 | [dev-dependencies] 19 | uuid = { version = "0.8", features = ["v4", "serde"] } 20 | serde = { version = "1.0", features = ["derive"] } 21 | tokio = { version = "1.12", features = ["macros", "rt-multi-thread"] } 22 | trybuild = "1.0" 23 | warp = "0.3" 24 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Copyright 2021 IOMentum 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 IOMentum 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `restest` 2 | 3 | Black-box integration test for REST APIs in Rust. 4 | 5 | `restest` provides primitives that allow to write REST API in a declarative 6 | manner. It leverages the Rust test framework and uses macro-assisted pattern 7 | tho assert for a pattern and add specified variables to scope. 8 | 9 | ## Adding to the `Cargo.toml` 10 | 11 | `restest` provides test-only code. As such, it can be added as a 12 | dev-dependency: 13 | 14 | ```TOML 15 | [dev-dependencies] 16 | restest = "0.1.0" 17 | ``` 18 | 19 | ## Example 20 | 21 | ```rust 22 | use restest::{assert_body_matches, path, Context, Request}; 23 | 24 | use serde::{Deserialize, Serialize}; 25 | use http::StatusCode; 26 | 27 | const CONTEXT: Context = Context::new().with_port(8080); 28 | 29 | let request = Request::post(path!["user/ghopper"]).with_body(PostUser { 30 | year_of_birth: 1943, 31 | }); 32 | 33 | let body = CONTEXT 34 | .run(request) 35 | .await 36 | .expect_status(StatusCode::OK) 37 | .await; 38 | 39 | assert_body_matches! { 40 | body, 41 | User { 42 | year_of_birth: 1943, 43 | .. 44 | } 45 | } 46 | 47 | struct PostUser { 48 | year_of_birth: usize, 49 | } 50 | 51 | struct User { 52 | year_of_birth: usize, 53 | id: Uuid 54 | } 55 | ``` 56 | 57 | ## Writing tests 58 | 59 | Writing tests using `restest` always follow the same patterns. They are 60 | described below. 61 | 62 | ### Specifying the context 63 | 64 | The `Context` object handles all the server-specific configuration: 65 | 66 | * which base URL should be used, 67 | * which port should be used. 68 | 69 | It can be created with `Context::new`. All its setters are `const`, so 70 | it can be initialized once for all the tests of a module: 71 | 72 | ```rust 73 | use restest::Context; 74 | 75 | const CONTEXT: Context = Context::new().with_port(8080); 76 | 77 | async fn test_first_route() { 78 | // Test code that use `CONTEXT` for a specific route 79 | } 80 | 81 | async fn test_second_route() { 82 | // Test code that use `CONTEXT` again for another route 83 | } 84 | ``` 85 | 86 | As we're running `async` code under the hood, all the tests must be `async`, 87 | hence the use of `tokio::test` 88 | 89 | ## Creating a request 90 | 91 | Let's focus on the test function itself. 92 | 93 | The first thing to do is to create a `Request` object. This object allows 94 | to specify characteristics about a specific request that is performed later. 95 | 96 | Running `Request::get` allows to construct a GET request to a specific 97 | URL. Header keys can be specified by calling the `with_header` method. The 98 | final `Request` is built by calling the `with_body` method, which allows to add 99 | a body. 100 | 101 | ```rust 102 | use restest::{path, Request}; 103 | 104 | let request = Request::get(path!["users", "scrabsha"]) 105 | .with_header("token", "mom-said-yes") 106 | .with_body(()); 107 | ``` 108 | 109 | Similarly, POST requests can be creating by using \[`Request::post`\] instead 110 | of \[`Request::get`\]. 111 | 112 | ## Performing the request 113 | 114 | Once that the `Request` object has been created, we can run the request by 115 | passing the `Request` to the `Context` when calling `Context::run`. Once 116 | `await`-ed, the `expect_status` method checks for the request status code and 117 | converts the response body to the expected output type. 118 | 119 | ```rust 120 | use http::StatusCode; 121 | use uuid::Uuid; 122 | use serde::Deserialize; 123 | 124 | let user: User = CONTEXT 125 | .run(request) 126 | .await 127 | .expect_status(StatusCode::OK) 128 | .await; 129 | 130 | struct User { 131 | name: String, 132 | age: u8, 133 | id: Uuid, 134 | } 135 | ``` 136 | 137 | ## Checking the response body 138 | 139 | Properties about the response body can be asserted with `assert_body_matches`. 140 | The macro supports the full rust pattern syntax, making it easy to check for 141 | expected values and variants. It also provides bindings, allowing you to bring 142 | data from the body in scope: 143 | 144 | ```rust 145 | use restest::assert_body_matches; 146 | 147 | assert_body_matches! { 148 | user, 149 | User { 150 | name: "Grace Hopper", 151 | age: 85, 152 | id, 153 | }, 154 | } 155 | 156 | // id is now a variable that can be used: 157 | println!("Grace Hopper has id `{}`", id); 158 | ``` 159 | 160 | The extracted variable can be used for next requests or more complex 161 | testing. 162 | 163 | *And that's it!* 164 | -------------------------------------------------------------------------------- /examples/user_server.rs: -------------------------------------------------------------------------------- 1 | //! # User server example 2 | //! 3 | //! *To find the quick start commands, scroll at the bottom of the 4 | //! documentation.* 5 | //! 6 | //! ## Description 7 | //! 8 | //! Let's model a small server which stores information about its users. 9 | //! More specifically, it stores their id (an Uuid) and year of birth. 10 | //! It exposes a REST API, where we can add a new user and query data about 11 | //! an user with a specific id. 12 | //! 13 | //! To simplify the thing, we save everything in memory. 14 | //! 15 | //! ## Testing the API 16 | //! 17 | //! The server must be started in a terminal, so that the testing code can query 18 | //! it. The server never ends, so it must be stopped by pressing Ctrl + C. 19 | //! 20 | //! The nightly toolchain must be used to compile the test code, but the server 21 | //! can be compiled with any toolchain. In order to avoid recompiling all the 22 | //! `dev-dependencies` each time, it is better to compile everything with the 23 | //! nightly toolchain. 24 | //! 25 | //! ## Commands 26 | //! 27 | //! $ cargo run --example user_server 28 | //! $ cargo test --example user_server_test 29 | 30 | use http::StatusCode; 31 | use std::{ 32 | collections::HashMap, 33 | sync::{Arc, Mutex}, 34 | }; 35 | 36 | use serde::{Deserialize, Serialize}; 37 | use uuid::Uuid; 38 | use warp::reply::*; 39 | use warp::{body, filters::method, path, Filter, Rejection, Reply}; 40 | 41 | /// An in-memory user database. 42 | #[derive(Clone, Debug, Default)] 43 | struct Database { 44 | /// We use a very complex type here because we need to share the hashmap 45 | /// across multiple threads and we want to allow concurrent modifications. 46 | users: Arc>>, 47 | } 48 | 49 | impl Database { 50 | fn new() -> Self { 51 | Self::default() 52 | } 53 | 54 | fn post_route(self) -> impl Filter + Clone { 55 | method::post() 56 | .and(body::json::()) 57 | .map(move |input| { 58 | let id = Uuid::new_v4(); 59 | let user_infos = Self::make_user(id, input); 60 | 61 | let response = with_status(json(&user_infos), StatusCode::CREATED); 62 | self.users.lock().unwrap().insert(id, user_infos); 63 | response 64 | }) 65 | } 66 | 67 | fn get_route(self) -> impl Filter + Clone { 68 | method::get() 69 | .and(path::param()) 70 | .map(move |id| match self.users.lock().unwrap().get(&id) { 71 | Some(user) => with_status(json(user), StatusCode::OK), 72 | None => with_status(json(&"Failed to get user infos"), StatusCode::NOT_FOUND), 73 | }) 74 | } 75 | 76 | fn delete_route(self) -> impl Filter + Clone { 77 | method::delete().and(path::param()).map(move |id| { 78 | match self.users.lock().unwrap().remove(&id) { 79 | Some(_) => with_status(json(&"User deleted"), StatusCode::OK), 80 | None => with_status(json(&"Failed to delete user"), StatusCode::NOT_FOUND), 81 | } 82 | }) 83 | } 84 | 85 | fn put_route(self) -> impl Filter + Clone { 86 | method::put() 87 | .and(path::param()) 88 | .and(body::json::()) 89 | .map(move |id, input| { 90 | let user_infos = Self::make_user(id, input); 91 | 92 | let response = json(&user_infos); 93 | match self.users.lock().unwrap().insert(id, user_infos) { 94 | Some(_) => with_status(response, StatusCode::OK), 95 | None => with_status(response, StatusCode::CREATED), 96 | } 97 | }) 98 | } 99 | 100 | fn make_user(id: Uuid, input: UserInfosInput) -> UserInfos { 101 | UserInfos { 102 | id, 103 | year_of_birth: input.year_of_birth, 104 | } 105 | } 106 | } 107 | 108 | #[derive(Clone, Debug, Serialize, PartialEq)] 109 | struct UserInfos { 110 | id: Uuid, 111 | /// Let's consider that everyone who's born before 0AD is dead now. 112 | year_of_birth: usize, 113 | } 114 | 115 | #[derive(Clone, Debug, PartialEq, Deserialize)] 116 | struct UserInfosInput { 117 | year_of_birth: usize, 118 | } 119 | 120 | #[tokio::main] 121 | async fn main() { 122 | let db = Database::new(); 123 | 124 | let post = path::path("users").and(db.clone().post_route()); 125 | let get = path::path("users").and(db.clone().get_route()); 126 | let put = path::path("users").and(db.clone().put_route()); 127 | let delete = path::path("users").and(db.delete_route()); 128 | 129 | let server = warp::serve(post.or(get).or(put).or(delete)).run(([127, 0, 0, 1], 8080)); 130 | 131 | server.await 132 | } 133 | -------------------------------------------------------------------------------- /examples/user_server_test.rs: -------------------------------------------------------------------------------- 1 | //! # User server test example 2 | //! 3 | //! *To find the quick start commands, scroll at the bottom of the 4 | //! documentation.* 5 | //! 6 | //! ## Description 7 | //! 8 | //! We're testing a small user database REST API using `restest`-provided 9 | //! macros. 10 | //! 11 | //! ## Testing the API 12 | //! 13 | //! The server must be started in a terminal, so that the testing code can query 14 | //! it. The server never ends, so it must be stopped by pressing Ctrl + C. 15 | //! 16 | //! The nightly toolchain must be used to compile the test code, but the server 17 | //! can be compiled with any toolchain. In order to avoid recompiling all the 18 | //! `dev-dependencies` each time, it is better to compile everything with the 19 | //! nightly toolchain. 20 | //! 21 | //! ## Commands 22 | //! 23 | //! $ cargo run --example user_server 24 | //! $ cargo test --example user_server_test 25 | 26 | // I'm sorry but I hate having so much warnings when checking the codebase. 27 | #![allow(dead_code, unused_imports)] 28 | 29 | use http::StatusCode; 30 | use restest::{assert_body_matches, path, Context, Request}; 31 | use serde::{Deserialize, Serialize}; 32 | use uuid::Uuid; 33 | 34 | /// The data we send to the server when using the `PUT` route. 35 | /// 36 | /// This does not need to be *exactly* the same as the datatype defined in 37 | /// `user_server`. The only constraint is that this must `Serialize` to a JSON 38 | /// body that is accepted by the server. 39 | #[derive(Serialize)] 40 | struct UserInput { 41 | year_of_birth: usize, 42 | } 43 | 44 | /// The data that the server sends us back when we add an user. 45 | /// 46 | /// Once again, the only constraint is that we must deserialize what the server 47 | /// responds to us. 48 | #[derive(Debug, Deserialize, PartialEq)] 49 | struct User { 50 | year_of_birth: usize, 51 | id: Uuid, 52 | } 53 | 54 | /// Let's tell to restest which port should be used for our tests: 55 | const CONTEXT: Context = Context::new().with_port(8080); 56 | 57 | /// Test POST route. 58 | /// 59 | /// We send a request adding a new user to the database, and tell what we expect 60 | /// as a response. 61 | #[tokio::test] 62 | pub async fn post_user() { 63 | // Let's create a Request object, representing what we're about to ask to 64 | // the server. 65 | let request = Request::post("users").with_body(UserInput { 66 | year_of_birth: 2000, 67 | }); 68 | 69 | // Now that we have our request object, we can ask our Context to run it. 70 | // 71 | // We also check that the response status is what we expect and deserialize 72 | // the body. 73 | let user = CONTEXT 74 | .run(request) 75 | .await 76 | .expect_status(StatusCode::CREATED) 77 | .await; 78 | 79 | assert_body_matches! { 80 | user, 81 | User { year_of_birth: 2000, .. }, 82 | }; 83 | } 84 | 85 | /// Test for the GET route. 86 | /// 87 | /// We add a new user to the database and get again its profile so that we 88 | /// can ensure that both profiles are equal. 89 | #[tokio::test] 90 | pub async fn get_user() { 91 | // Create a new Request object, just as we did for the post_user test. 92 | let request = Request::post("users").with_body(UserInput { 93 | year_of_birth: 2000, 94 | }); 95 | 96 | // Similarly, execute the said request and get the output. 97 | // This time using ensure_status to return a Result 98 | let user = CONTEXT 99 | .run(request) 100 | .await 101 | .ensure_status(StatusCode::CREATED) 102 | .await 103 | .unwrap(); 104 | 105 | // Here is a little trick: we need to get back the user ID so that we can 106 | // reuse it for the next request. To do so, we bind the variable id to the 107 | // field id of the object we got in response. 108 | assert_body_matches! { 109 | user, 110 | User { id, year_of_birth: 2000 }, 111 | }; 112 | 113 | // We can now use the id variable to create another request. 114 | let request = Request::get(path!["users", id]); 115 | 116 | let response = CONTEXT 117 | .run(request) 118 | .await 119 | .expect_status(StatusCode::OK) 120 | .await; 121 | 122 | // We can ensure that the returned year of birth is now correct. 123 | assert_body_matches! { 124 | response, 125 | User { year_of_birth: 2000, .. }, 126 | }; 127 | } 128 | 129 | /// Test for the ensure_status method. 130 | /// 131 | /// We add a new user to the database and ensure with a wrong status code 132 | /// making sure it returns an error. 133 | #[tokio::test] 134 | pub async fn ensure_status_failing() { 135 | // Create a new Request object, just as we did for the post_user test. 136 | let request = Request::post("users").with_body(UserInput { 137 | year_of_birth: 2000, 138 | }); 139 | 140 | // Similarly, execute the said request and get the output. 141 | // This time, trying to make the ensure_status fail with a wrong status code 142 | let response = CONTEXT 143 | .run(request) 144 | .await 145 | .ensure_status::(StatusCode::ACCEPTED) 146 | .await; 147 | 148 | // testing if it is returning an error 149 | assert!(response.is_err()); 150 | } 151 | 152 | /// Test for the DELETE route. 153 | /// 154 | /// We add a new user to the database, then delete it and ensure that the 155 | /// server returns a 200 status code. 156 | /// 157 | /// We then try to delete the same user again and ensure that the server 158 | /// returns a 404 status code. 159 | #[tokio::test] 160 | pub async fn delete_user() { 161 | // Create a new Request object, just as we did for the post_user test. 162 | let request = Request::post("users").with_body(UserInput { 163 | year_of_birth: 2000, 164 | }); 165 | 166 | // Similarly, execute the said request and get the output. 167 | let user = CONTEXT 168 | .run(request) 169 | .await 170 | .expect_status(StatusCode::CREATED) 171 | .await; 172 | 173 | assert_body_matches! { 174 | user, 175 | User { id, year_of_birth: 2000 }, 176 | }; 177 | 178 | let request = Request::delete(path!["users", id]); 179 | 180 | CONTEXT 181 | .run(&request) 182 | .await 183 | .expect_status::(StatusCode::OK) 184 | .await; 185 | 186 | // We try to delete the same user again and ensure that the server 187 | // returns a 404 status code. 188 | CONTEXT 189 | .run(request) 190 | .await 191 | .expect_status::(StatusCode::NOT_FOUND) 192 | .await; 193 | } 194 | 195 | /// Test for the PUT route. 196 | /// 197 | /// We add a new user to the database, then update its profile and ensure that 198 | /// the server returns a 200 status code. 199 | #[tokio::test] 200 | pub async fn put_user() { 201 | // Create a new Request object, just as we did for the post_user test. 202 | let request = Request::post("users") 203 | .with_body(UserInput { 204 | year_of_birth: 2000, 205 | }) 206 | .with_context("Create a user"); 207 | 208 | // Similarly, execute the said request and get the output. 209 | let user = CONTEXT 210 | .run(request) 211 | .await 212 | .expect_status(StatusCode::CREATED) 213 | .await; 214 | 215 | assert_body_matches! { 216 | user, 217 | User { id, year_of_birth: 2000 }, 218 | }; 219 | 220 | // We can now use the id variable to create another request. 221 | let request = Request::put(path!["users", id]).with_body(UserInput { 222 | year_of_birth: 2001, 223 | }); 224 | 225 | let response = CONTEXT 226 | .run(request) 227 | .await 228 | .expect_status(StatusCode::OK) 229 | .await; 230 | 231 | // We can ensure that the returned year of birth is now correct. 232 | assert_body_matches! { 233 | response, 234 | User { year_of_birth: 2001, .. }, 235 | }; 236 | } 237 | 238 | fn main() { 239 | panic!("Usage: cargo test --example user_server_test"); 240 | } 241 | -------------------------------------------------------------------------------- /restest_macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "restest_macros" 3 | version = "0.1.0" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | description = "Macros used by the restest crate" 7 | 8 | [lib] 9 | proc-macro = true 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | proc-macro2 = "1.0" 15 | quote = "1.0" 16 | syn = { version = "1.0", features = ["visit", "visit-mut", "full"] } 17 | -------------------------------------------------------------------------------- /restest_macros/README.md: -------------------------------------------------------------------------------- 1 | # restest_macros 2 | 3 | Macros for the restest crate 4 | 5 | ## Code of Conduct 6 | 7 | We have a Code of Conduct so as to create a more enjoyable community and 8 | work environment. Please see the [CODE_OF_CONDUCT](../CODE_OF_CONDUCT.md) 9 | file for more details. 10 | 11 | ## License 12 | 13 | Licensed under either of 14 | 15 | - Apache License, Version 2.0 ([LICENSE-APACHE](../LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 16 | - MIT license ([LICENSE-MIT](../LICENSE-MIT) or http://opensource.org/licenses/MIT) 17 | 18 | at your option. 19 | 20 | Dual MIT/Apache2 is strictly more permissive 21 | -------------------------------------------------------------------------------- /restest_macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate proc_macro; 2 | 3 | use std::{collections::VecDeque, iter}; 4 | 5 | use proc_macro2::Span; 6 | use quote::{format_ident, quote, ToTokens}; 7 | use syn::{ 8 | parse::{Parse, ParseStream}, 9 | parse_macro_input, 10 | punctuated::{Pair, Punctuated}, 11 | token::{Brace, Comma, FatArrow, Paren}, 12 | visit::Visit, 13 | visit_mut::{self, VisitMut}, 14 | Arm, Expr, ExprLit, ExprMatch, ExprTuple, Ident, Lit, LitStr, Local, Pat, PatIdent, PatLit, 15 | PatSlice, PatTuple, PatWild, Stmt, Token, 16 | }; 17 | 18 | #[proc_macro] 19 | pub fn assert_body_matches(input: proc_macro::TokenStream) -> proc_macro::TokenStream { 20 | let input = parse_macro_input!(input as BodyMatchCall); 21 | 22 | proc_macro::TokenStream::from(input.expand().to_token_stream()) 23 | } 24 | 25 | impl BodyMatchCall { 26 | fn expand(mut self) -> Stmt { 27 | // We need to do three things: 28 | // 29 | // - extract the identifier that are brought in scope by the macro 30 | // call, 31 | // 32 | // - alter the pattern so that string literals allow to match String, 33 | // 34 | // - transform the pattern in a nested match expression, with one 35 | // level of nesting for each slice pattern. 36 | 37 | let let_token = Token![let](Span::call_site()); 38 | let equal = Token![=](Span::call_site()); 39 | let semi_token = Token![;](Span::call_site()); 40 | 41 | let (bindings, return_expr) = 42 | BindingPatternsExtractor::new(&self.pat).expand_bindings_and_return_expr(); 43 | let guard_condition = StringLiteralPatternModifier::new(&mut self.pat).expand_guard_expr(); 44 | let match_expr = 45 | SlicePatternModifier::new(self.value, self.pat, guard_condition, return_expr.into()) 46 | .expand(); 47 | 48 | let pat = bindings.into(); 49 | let match_expr = Box::new(match_expr.into()); 50 | 51 | Stmt::Local(Local { 52 | attrs: Vec::new(), 53 | let_token, 54 | pat, 55 | init: Some((equal, match_expr)), 56 | semi_token, 57 | }) 58 | } 59 | } 60 | 61 | impl Parse for BodyMatchCall { 62 | fn parse(input: ParseStream) -> syn::Result { 63 | Ok(BodyMatchCall { 64 | value: input.parse()?, 65 | _comma1: input.parse()?, 66 | pat: input.parse()?, 67 | _comma2: input.parse()?, 68 | }) 69 | } 70 | } 71 | 72 | struct BodyMatchCall { 73 | value: Expr, 74 | _comma1: Token![,], 75 | pat: Pat, 76 | _comma2: Option, 77 | } 78 | 79 | /// Allows to extract a list of all the identifiers that are brought in scope 80 | /// by a given pattern. 81 | /// 82 | /// This allows us to generate the final body of the innermost match pattern 83 | /// that `assert_body_matches` will expand to. 84 | /// 85 | /// # How 86 | /// 87 | /// [`BindingPatternsExtractor`] implements [`Visit`], which allows to 88 | /// recursively visit the entire AST. We use this to visit a given pattern 89 | /// and ensure extract every binding pattern. 90 | /// 91 | /// # Example 92 | /// 93 | /// The following pattern: 94 | /// 95 | /// ```none 96 | /// Foo { 97 | /// field, 98 | /// inner: Bar { 99 | /// value: 42, 100 | /// other_value, 101 | /// }, 102 | /// final_value, 103 | /// } 104 | /// ``` 105 | /// 106 | /// Brings the following identifiers in scope: 107 | /// - `field`, 108 | /// - `other_value`, 109 | /// - `final_value`. 110 | #[derive(Default)] 111 | struct BindingPatternsExtractor<'pat> { 112 | bindings: Vec<&'pat Ident>, 113 | } 114 | 115 | impl<'pat> BindingPatternsExtractor<'pat> { 116 | fn new(pat: &'pat Pat) -> BindingPatternsExtractor<'pat> { 117 | let mut this = BindingPatternsExtractor::default(); 118 | this.visit_pat(pat); 119 | this 120 | } 121 | 122 | fn expand_bindings_and_return_expr(self) -> (PatTuple, ExprTuple) { 123 | let paren_token = Paren { 124 | span: Span::call_site(), 125 | }; 126 | 127 | let bindings = self.mk_bindings(paren_token); 128 | let return_expr = self.mk_return_expr(paren_token); 129 | 130 | (bindings, return_expr) 131 | } 132 | 133 | fn mk_bindings(&self, paren_token: Paren) -> PatTuple { 134 | let elems = self 135 | .bindings 136 | .iter() 137 | .copied() 138 | .cloned() 139 | .map(Self::mk_ident_pat) 140 | .map(|i| Pair::Punctuated(i, Comma::default())) 141 | .collect(); 142 | 143 | PatTuple { 144 | attrs: Vec::new(), 145 | paren_token, 146 | elems, 147 | } 148 | } 149 | 150 | fn mk_return_expr(self, paren_token: Paren) -> ExprTuple { 151 | let elems = self 152 | .bindings 153 | .into_iter() 154 | .cloned() 155 | .map(Self::mk_ident_expr) 156 | .map(|i| Pair::Punctuated(i, Comma::default())) 157 | .collect::>(); 158 | 159 | ExprTuple { 160 | attrs: Vec::new(), 161 | paren_token, 162 | elems, 163 | } 164 | } 165 | 166 | fn mk_ident_expr(ident: Ident) -> Expr { 167 | Expr::Verbatim(quote! { #ident }) 168 | } 169 | 170 | fn mk_ident_pat(ident: Ident) -> Pat { 171 | Pat::Ident(PatIdent { 172 | attrs: Vec::new(), 173 | by_ref: None, 174 | mutability: None, 175 | ident, 176 | subpat: None, 177 | }) 178 | } 179 | } 180 | 181 | impl<'pat> Visit<'pat> for BindingPatternsExtractor<'pat> { 182 | fn visit_pat_ident(&mut self, i: &'pat PatIdent) { 183 | self.bindings.push(&i.ident); 184 | } 185 | } 186 | 187 | /// Allows to perform pattern matching over `String` using literals. 188 | /// 189 | /// To do so, we need to alter the pattern and change every instance of string 190 | /// literal pattern into a binding and check for equality in the final guard. 191 | /// 192 | /// # How 193 | /// 194 | /// [`StringLiteralPatternModifier`] implements [`VisitMut`], which allows us to 195 | /// recursively visit and alter the AST. Here, we use this to visit and alter a 196 | /// given pattern. Specifically, we change all the string literal pattern to be 197 | /// bindings of a given identifier and keep track of which identifier 198 | /// corresponds to which string literal. 199 | /// 200 | /// # Example 201 | /// 202 | /// The following pattern: 203 | /// 204 | /// ```none 205 | /// Foo { 206 | /// field: "string literal 1", 207 | /// inner: Bar { 208 | /// other_field: "string literal 2", 209 | /// }, 210 | /// final: "string literal 3", 211 | /// } 212 | /// ``` 213 | /// 214 | /// Will be transformed to: 215 | /// 216 | /// ```none 217 | /// Foo { 218 | /// field: __restest__str_0, 219 | /// inner: Bar { 220 | /// other_field: __restest__str_1, 221 | /// }, 222 | /// final: __restest__str_2, 223 | /// } 224 | /// ``` 225 | /// 226 | /// And will generate the following conditions: 227 | /// - `__restest__str_0 == "string literal 1"`, 228 | /// - `__restest__str_1 == "string literal 2"`, 229 | /// - `__restest__str_2 == "string literal 3"`. 230 | #[derive(Default)] 231 | struct StringLiteralPatternModifier { 232 | conditions: Vec<(Ident, LitStr)>, 233 | } 234 | 235 | impl StringLiteralPatternModifier { 236 | fn new(pat: &mut Pat) -> StringLiteralPatternModifier { 237 | let mut this = StringLiteralPatternModifier::default(); 238 | 239 | this.visit_pat_mut(pat); 240 | this 241 | } 242 | 243 | fn expand_guard_expr(self) -> Expr { 244 | let (names, values): (Vec<_>, Vec<_>) = self.conditions.into_iter().unzip(); 245 | Expr::Verbatim(quote! { 246 | true #( && #names == #values )* 247 | }) 248 | } 249 | 250 | fn add_literal_pattern(&mut self, lit: LitStr) -> Ident { 251 | let name = self.mk_ident(); 252 | self.conditions.push((name.clone(), lit)); 253 | name 254 | } 255 | 256 | fn alter_pattern(pat: &mut Pat, ident: Ident) { 257 | *pat = Pat::Ident(PatIdent { 258 | attrs: Vec::new(), 259 | by_ref: None, 260 | mutability: None, 261 | ident, 262 | subpat: None, 263 | }) 264 | } 265 | 266 | fn mk_ident(&self) -> Ident { 267 | format_ident!("__restest__str_{}", self.conditions.len()) 268 | } 269 | } 270 | 271 | impl VisitMut for StringLiteralPatternModifier { 272 | fn visit_pat_mut(&mut self, pat: &mut Pat) { 273 | match pat { 274 | Pat::Lit(PatLit { expr, .. }) => match expr.as_ref() { 275 | Expr::Lit(ExprLit { 276 | lit: Lit::Str(lit), .. 277 | }) => { 278 | let ident = self.add_literal_pattern(lit.clone()); 279 | Self::alter_pattern(pat, ident); 280 | } 281 | 282 | _ => visit_mut::visit_pat_mut(self, pat), 283 | }, 284 | 285 | _ => visit_mut::visit_pat_mut(self, pat), 286 | } 287 | } 288 | } 289 | 290 | /// Allows to encode and expand a match expression that accepts slices patterns 291 | /// for `Vec`. 292 | /// 293 | /// # How 294 | /// 295 | /// We use [`VisitMut`] to visit and alter the pattern. We transform every slice 296 | /// pattern into a binding of a unique identifier, and match on its content in 297 | /// an inner expression. 298 | /// 299 | /// This results in multiple, nested match expressions, each of them matching 300 | /// over exactly one slice pattern. 301 | /// 302 | /// # Example 303 | /// 304 | /// Given the following pattern: 305 | /// 306 | /// ```none 307 | /// [ // (1) 308 | /// [a, 2, 3], // (2) 309 | /// [b, 5, 6], // (3) 310 | /// ] // (1) 311 | /// ``` 312 | /// 313 | /// In the first iteration, we only alter the slice pattern delimited by `(1)`. 314 | /// This will produce the following match expression: 315 | /// 316 | /// ```none 317 | /// match { 318 | /// __restest__slice_0 => match __restest__slice_0[..] { 319 | /// [__restest__slice_1, __restest__slice_2] => { /* ... */ }, 320 | /// } 321 | /// } 322 | /// ``` 323 | /// 324 | /// Next iteration alters slice pattern `(2)` and leads us to: 325 | /// 326 | /// ```none 327 | /// match { 328 | /// __restest__slice_0 => match __restest__slice_0[..] { 329 | /// [__restest__slice_1, __restest__slice_2] => match __restest__slice_1[..] { 330 | /// [a, 2, 3] => { /* ... */ }, 331 | /// } 332 | /// } 333 | /// } 334 | /// ``` 335 | /// 336 | /// The last iteration alters slice pattern `(3)` and expands to: 337 | /// 338 | /// ```none 339 | /// match { 340 | /// __restest__slice_0 => match __restest__slice_0[..] { 341 | /// [__restest__slice_1, __restest__slice_2] => match __restest__slice_1[..] { 342 | /// [a, 2, 3] => match __restest__slice_2[..] { 343 | /// [b, 5, 6] => { /* final expression */ } 344 | /// }, 345 | /// } 346 | /// } 347 | /// } 348 | /// ``` 349 | struct SlicePatternModifier { 350 | first_expr: Expr, 351 | first_pat: Pat, 352 | nested_matches: Vec<(Expr, Pat)>, 353 | final_guard_condition: Expr, 354 | return_expr: Expr, 355 | } 356 | 357 | impl SlicePatternModifier { 358 | fn new( 359 | matched_expr: Expr, 360 | pat: Pat, 361 | final_guard_condition: Expr, 362 | return_expr: Expr, 363 | ) -> SlicePatternModifier { 364 | let mut sub_slice_patterns = Vec::new(); 365 | 366 | let mut replacer = SlicePatternReplacer::new(); 367 | let pat = replacer.alter_initial_pattern(pat); 368 | 369 | let mut unaltered_slice_patterns = VecDeque::from_iter(replacer.extracted_slice_patterns()); 370 | 371 | while let Some((ident, pat)) = unaltered_slice_patterns.pop_front() { 372 | let mut replacer = SlicePatternReplacer::new(); 373 | let expr = Self::mk_match_expr(ident); 374 | let pat = replacer.alter_pat_slice(pat).into(); 375 | 376 | sub_slice_patterns.push((expr, pat)); 377 | unaltered_slice_patterns.extend(replacer.extracted_slice_patterns()); 378 | } 379 | 380 | SlicePatternModifier { 381 | first_expr: matched_expr, 382 | first_pat: pat, 383 | nested_matches: sub_slice_patterns, 384 | final_guard_condition, 385 | return_expr, 386 | } 387 | } 388 | 389 | fn expand(self) -> ExprMatch { 390 | let mut nesting = iter::once((self.first_expr, self.first_pat)) 391 | .chain(self.nested_matches) 392 | .rev(); 393 | 394 | let (innermost_expr, innermost_pat) = nesting.next().unwrap(); 395 | 396 | let guard = ( 397 | ::default(), 398 | Box::new(self.final_guard_condition), 399 | ); 400 | 401 | let arms = vec![ 402 | Self::mk_arm(innermost_pat, Some(guard), self.return_expr), 403 | Self::catchall_arm(), 404 | ]; 405 | 406 | let innermost_match = ExprMatch { 407 | attrs: Vec::new(), 408 | match_token: ::default(), 409 | expr: Box::new(innermost_expr), 410 | brace_token: Brace::default(), 411 | arms, 412 | }; 413 | 414 | nesting.fold(innermost_match, Self::nest_match) 415 | } 416 | 417 | fn nest_match(inner: ExprMatch, (expr, pat): (Expr, Pat)) -> ExprMatch { 418 | let match_token = ::default(); 419 | let expr = Box::new(expr); 420 | let brace_token = Brace::default(); 421 | let arms = vec![Self::mk_arm(pat, None, inner.into()), Self::catchall_arm()]; 422 | 423 | ExprMatch { 424 | attrs: Vec::new(), 425 | match_token, 426 | expr, 427 | brace_token, 428 | arms, 429 | } 430 | } 431 | 432 | fn mk_arm(pat: Pat, guard: Option<(Token![if], Box)>, body: Expr) -> Arm { 433 | let body = Box::new(body); 434 | Arm { 435 | attrs: Vec::new(), 436 | pat, 437 | guard, 438 | fat_arrow_token: FatArrow::default(), 439 | body, 440 | comma: Some(Comma::default()), 441 | } 442 | } 443 | 444 | fn catchall_arm() -> Arm { 445 | Arm { 446 | attrs: Vec::new(), 447 | pat: Pat::Wild(PatWild { 448 | attrs: Vec::new(), 449 | underscore_token: Token![_](Span::mixed_site()), 450 | }), 451 | guard: None, 452 | fat_arrow_token: Token![=>](Span::mixed_site()), 453 | body: Box::new(Self::mk_panic_expr()), 454 | comma: Some(Token![,](Span::mixed_site())), 455 | } 456 | } 457 | 458 | fn mk_match_expr(ident: Ident) -> Expr { 459 | Expr::Verbatim(quote! { #ident[..] }) 460 | } 461 | 462 | fn mk_panic_expr() -> Expr { 463 | Expr::Verbatim(quote! { panic!("Matching failed")}) 464 | } 465 | } 466 | 467 | /// Helper struct for [`SlicePatternReplacer`]. 468 | /// 469 | /// Alters slice pattern, stores it in memory and stores it internally. 470 | /// 471 | /// We only alter outermost slice patterns. This process is repeated multiple 472 | /// times. 473 | struct SlicePatternReplacer { 474 | slices: Vec<(Ident, PatSlice)>, 475 | } 476 | 477 | impl SlicePatternReplacer { 478 | fn new() -> SlicePatternReplacer { 479 | SlicePatternReplacer { slices: Vec::new() } 480 | } 481 | 482 | fn alter_initial_pattern(&mut self, mut pat: Pat) -> Pat { 483 | self.visit_pat_mut(&mut pat); 484 | pat 485 | } 486 | 487 | fn alter_pat_slice(&mut self, mut pat: PatSlice) -> PatSlice { 488 | self.visit_pat_slice_mut(&mut pat); 489 | pat 490 | } 491 | 492 | fn extracted_slice_patterns(self) -> Vec<(Ident, PatSlice)> { 493 | self.slices 494 | } 495 | 496 | fn add_slice_pattern(&mut self, pat: &mut Pat, slice: PatSlice) { 497 | let ident = self.mk_internal_slice_ident(); 498 | self.slices.push((ident.clone(), slice)); 499 | 500 | let pat_ident = PatIdent { 501 | attrs: Vec::new(), 502 | by_ref: None, 503 | mutability: None, 504 | ident, 505 | subpat: None, 506 | }; 507 | 508 | *pat = Pat::Ident(pat_ident); 509 | } 510 | 511 | fn mk_internal_slice_ident(&self) -> Ident { 512 | format_ident!("__restest__array_{}", self.slices.len()) 513 | } 514 | } 515 | 516 | impl VisitMut for SlicePatternReplacer { 517 | fn visit_pat_mut(&mut self, pat: &mut Pat) { 518 | match pat { 519 | Pat::Slice(slice) => { 520 | let slice = slice.clone(); 521 | self.add_slice_pattern(pat, slice); 522 | } 523 | 524 | _ => visit_mut::visit_pat_mut(self, pat), 525 | } 526 | } 527 | } 528 | 529 | #[cfg(test)] 530 | mod tests { 531 | use quote::ToTokens; 532 | use syn::parse_quote; 533 | 534 | use super::*; 535 | 536 | mod binding_patterns_extractor { 537 | use super::*; 538 | 539 | #[test] 540 | fn extraction_simple() { 541 | let pat = parse_quote! { foo }; 542 | 543 | let return_expr = BindingPatternsExtractor::new(&pat) 544 | .expand_bindings_and_return_expr() 545 | .1; 546 | 547 | let left = return_expr.to_token_stream().to_string(); 548 | let right = quote! { (foo,) }.to_string(); 549 | 550 | assert_eq!(left, right); 551 | } 552 | 553 | #[test] 554 | fn extraction_in_subpattern() { 555 | let pat = parse_quote! { [foo, bar, .., baz ]}; 556 | 557 | let return_expr = BindingPatternsExtractor::new(&pat) 558 | .expand_bindings_and_return_expr() 559 | .1; 560 | 561 | let left = return_expr.to_token_stream().to_string(); 562 | let right = quote! { (foo, bar, baz,) }.to_string(); 563 | 564 | assert_eq!(left, right); 565 | } 566 | 567 | #[test] 568 | fn handles_at_pattern() { 569 | let pat = parse_quote! { foo @ [] }; 570 | 571 | let return_expr = BindingPatternsExtractor::new(&pat) 572 | .expand_bindings_and_return_expr() 573 | .1; 574 | 575 | let left = return_expr.to_token_stream().to_string(); 576 | let right = quote! { (foo,) }.to_string(); 577 | 578 | assert_eq!(left, right); 579 | } 580 | } 581 | 582 | mod string_literal_modifier { 583 | use super::*; 584 | 585 | #[test] 586 | fn simple_alteration() { 587 | let mut pat = parse_quote! { "foo" }; 588 | 589 | let _ = StringLiteralPatternModifier::new(&mut pat); 590 | 591 | let left = pat.to_token_stream().to_string(); 592 | let right = quote! { 593 | __restest__str_0 594 | } 595 | .to_string(); 596 | 597 | assert_eq!(left, right); 598 | } 599 | 600 | #[test] 601 | fn simple_guard_condition() { 602 | let mut pat = parse_quote! { "foo" }; 603 | 604 | let modifier = StringLiteralPatternModifier::new(&mut pat); 605 | 606 | let left = modifier.expand_guard_expr().to_token_stream().to_string(); 607 | let right = quote! { 608 | true && __restest__str_0 == "foo" 609 | } 610 | .to_string(); 611 | 612 | assert_eq!(left, right); 613 | } 614 | 615 | #[test] 616 | fn alteration_in_subpatterns() { 617 | let mut pat = parse_quote! { 618 | [ 619 | Foo { bar: "bar" }, 620 | ("42"), 621 | [["hello"]], 622 | ] 623 | }; 624 | 625 | let _ = StringLiteralPatternModifier::new(&mut pat); 626 | 627 | let left = pat.to_token_stream().to_string(); 628 | let right = quote! { 629 | [ 630 | Foo { bar: __restest__str_0 }, 631 | (__restest__str_1), 632 | [[__restest__str_2]], 633 | ] 634 | } 635 | .to_string(); 636 | 637 | assert_eq!(left, right); 638 | } 639 | 640 | #[test] 641 | fn expansion_in_subpatterns() { 642 | let mut pat = parse_quote! { 643 | [ 644 | Foo { bar: "bar" }, 645 | ("42"), 646 | [["hello"]], 647 | ] 648 | }; 649 | 650 | let left = StringLiteralPatternModifier::new(&mut pat) 651 | .expand_guard_expr() 652 | .to_token_stream() 653 | .to_string(); 654 | 655 | let right = quote! { 656 | true 657 | && __restest__str_0 == "bar" 658 | && __restest__str_1 == "42" 659 | && __restest__str_2 == "hello" 660 | } 661 | .to_string(); 662 | 663 | assert_eq!(left, right); 664 | } 665 | } 666 | 667 | #[test] 668 | fn expand_2_base_case() { 669 | let call: BodyMatchCall = parse_quote! { 670 | foo, 671 | [a, b, c], 672 | }; 673 | 674 | let left = call.expand().to_token_stream().to_string(); 675 | 676 | let right = quote! { 677 | let (a, b, c,) = match foo { 678 | __restest__array_0 => match __restest__array_0[..] { 679 | [a, b, c] if true => (a, b, c,), 680 | _ => panic!("Matching failed"), 681 | }, 682 | _ => panic!("Matching failed"), 683 | }; 684 | } 685 | .to_string(); 686 | 687 | assert_eq!(left, right); 688 | } 689 | 690 | #[test] 691 | fn expand_2_with_recursion() { 692 | let call: BodyMatchCall = parse_quote! { 693 | foo, 694 | [[a], b, c], 695 | }; 696 | 697 | let left = call.expand().to_token_stream().to_string(); 698 | 699 | let right = quote! { 700 | let (a, b, c,) = match foo { 701 | __restest__array_0 => match __restest__array_0[..] { 702 | [__restest__array_0, b, c] => match __restest__array_0[..] { 703 | [a] if true => (a, b, c,), 704 | _ => panic!("Matching failed"), 705 | }, 706 | _ => panic!("Matching failed"), 707 | }, 708 | _ => panic!("Matching failed"), 709 | }; 710 | } 711 | .to_string(); 712 | 713 | assert_eq!(left, right); 714 | } 715 | 716 | #[test] 717 | fn expand_2_more_than_one() { 718 | let call: BodyMatchCall = parse_quote! { 719 | foo, 720 | ([foo], [bar]), 721 | }; 722 | 723 | let left = call.expand().to_token_stream().to_string(); 724 | 725 | let right = quote! { 726 | let (foo, bar,) = match foo { 727 | (__restest__array_0, __restest__array_1) => match __restest__array_0[..] { 728 | [foo] => match __restest__array_1[..] { 729 | [bar] if true => (foo, bar,), 730 | _ => panic!("Matching failed"), 731 | }, 732 | _ => panic!("Matching failed"), 733 | }, 734 | _ => panic!("Matching failed"), 735 | }; 736 | } 737 | .to_string(); 738 | 739 | assert_eq!(left, right); 740 | } 741 | } 742 | -------------------------------------------------------------------------------- /src/context.rs: -------------------------------------------------------------------------------- 1 | //! Hold information about the backend we're about to query. 2 | //! 3 | //! This module provides the [`Context`] type, whose goal is to store 4 | //! information about the backend (its URL base, its port) and to run a 5 | //! [`Request`]. 6 | 7 | use http::{header::HeaderName, HeaderMap, HeaderValue}; 8 | use reqwest::Client; 9 | use serde::Serialize; 10 | 11 | use crate::request::{Method, Request, RequestResult}; 12 | 13 | /// A structure that holds information about the backend we're about to query. 14 | /// 15 | /// All its setters are `const`, meaning it can be placed in a module, and 16 | /// accessed from anywhere in the module. 17 | /// 18 | /// # Example 19 | /// 20 | /// ```rust 21 | /// use restest::{Context, Request}; 22 | /// 23 | /// const CONTEXT: Context = Context::new() 24 | /// .with_port(80) 25 | /// .with_host("http://localhost"); 26 | /// 27 | /// #[tokio::test] 28 | /// async fn first_test() { 29 | /// // Use CONTEXT.run(...) to run a request. 30 | /// } 31 | /// 32 | /// #[tokio::test] 33 | /// async fn second_test() { 34 | /// // Use CONTEXT.run(...) to run another request. 35 | /// } 36 | /// ``` 37 | pub struct Context { 38 | host: &'static str, 39 | port: u16, 40 | } 41 | 42 | impl Context { 43 | /// Creates a new context with default values. 44 | /// 45 | /// The default host is localhost. 46 | /// 47 | /// The default port is port `80`. 48 | pub const fn new() -> Context { 49 | Context { 50 | host: "http://localhost", 51 | port: 80, 52 | } 53 | } 54 | 55 | /// Sets a host value. 56 | /// 57 | /// The previously-set host is discarded. 58 | pub const fn with_host(self, host: &'static str) -> Context { 59 | let port = self.port; 60 | 61 | Context { host, port } 62 | } 63 | 64 | /// Sets a port value. 65 | /// 66 | /// The previously-set port is discarded. 67 | pub const fn with_port(self, port: u16) -> Context { 68 | let host = self.host; 69 | 70 | Context { host, port } 71 | } 72 | 73 | /// Runs a request. 74 | /// 75 | /// This function performs I/O, therefore it is marked as `async`. 76 | pub async fn run(&self, request: R) -> RequestResult 77 | where 78 | I: Serialize, 79 | R: AsRef>, 80 | { 81 | let request = request.as_ref(); 82 | let client = reqwest::Client::new(); 83 | 84 | let create_request = match request.method { 85 | Method::Get => Client::get, 86 | Method::Post => Client::post, 87 | Method::Put => Client::put, 88 | Method::Delete => Client::delete, 89 | }; 90 | 91 | let url = format!("{}:{}{}", self.host, self.port, request.url); 92 | 93 | let headers = request 94 | .header 95 | .iter() 96 | .map(|(k, v)| { 97 | ( 98 | k.parse::() 99 | .expect("Header name conversion failed"), 100 | v.parse::() 101 | .expect("Header value conversion failed"), 102 | ) 103 | }) 104 | .collect::>(); 105 | 106 | let response = create_request(&client, url) 107 | .headers(headers) 108 | .json(&request.body) 109 | .send() 110 | .await 111 | .expect("Request failed"); 112 | 113 | RequestResult { 114 | response, 115 | context_description: request.context_description.clone(), 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | 3 | //! Black-box integration test for REST APIs in Rust. 4 | //! 5 | //! `restest` provides primitives that allow to write REST API in a declarative 6 | //! manner. It leverages the Rust test framework and uses macro-assisted pattern 7 | //! tho assert for a pattern and add specified variables to scope. 8 | //! 9 | //! # Adding to the `Cargo.toml` 10 | //! 11 | //! `restest` provides test-only code. As such, it can be added as a 12 | //! dev-dependency: 13 | //! 14 | #![doc = dep_doc::dev_dep_doc!()] 15 | //! 16 | //! # Example 17 | //! 18 | //! ```no_run 19 | //! use restest::{assert_body_matches, path, Context, Request}; 20 | //! 21 | //! use serde::{Deserialize, Serialize}; 22 | //! use http::StatusCode; 23 | //! 24 | //! const CONTEXT: Context = Context::new().with_port(8080); 25 | //! 26 | //! # #[tokio::main] 27 | //! # async fn main() { 28 | //! let request = Request::post(path!["user/ghopper"]).with_body(PostUser { 29 | //! year_of_birth: 1943, 30 | //! }); 31 | //! 32 | //! let body = CONTEXT 33 | //! .run(request) 34 | //! .await 35 | //! .expect_status(StatusCode::OK) 36 | //! .await; 37 | //! 38 | //! assert_body_matches! { 39 | //! body, 40 | //! User { 41 | //! year_of_birth: 1943, 42 | //! .. 43 | //! } 44 | //! } 45 | //! # } 46 | //! 47 | //! #[derive(Debug, Serialize)] 48 | //! struct PostUser { 49 | //! year_of_birth: usize, 50 | //! } 51 | //! 52 | //! #[derive(Debug, Deserialize)] 53 | //! struct User { 54 | //! year_of_birth: usize, 55 | //! id: Uuid 56 | //! } 57 | //! # #[derive(Debug, Deserialize)] 58 | //! # struct Uuid; 59 | //! ``` 60 | //! 61 | //! # Writing tests 62 | //! 63 | //! Writing tests using `restest` always follow the same patterns. They are 64 | //! described below. 65 | //! 66 | //! ## Specifying the context 67 | //! 68 | //! The [`Context`] object handles all the server-specific configuration: 69 | //! - which base URL should be used, 70 | //! - which port should be used. 71 | //! 72 | //! It can be created with [`Context::new`]. All its setters are `const`, so 73 | //! it can be initialized once for all the tests of a module: 74 | //! 75 | //! ```rust 76 | //! use restest::Context; 77 | //! 78 | //! const CONTEXT: Context = Context::new().with_port(8080); 79 | //! 80 | //! #[tokio::test] 81 | //! async fn test_first_route() { 82 | //! // Test code that use `CONTEXT` for a specific route 83 | //! } 84 | //! 85 | //! #[tokio::test] 86 | //! async fn test_second_route() { 87 | //! // Test code that use `CONTEXT` again for another route 88 | //! } 89 | //! ``` 90 | //! 91 | //! As we're running `async` code under the hood, all the tests must be `async`, 92 | //! hence the use of `tokio::test` 93 | //! 94 | //! # Creating a request 95 | //! 96 | //! Let's focus on the test function itself. 97 | //! 98 | //! The first thing to do is to create a [`Request`] object. This object allows 99 | //! to specify characteristics about a specific request that is performed later. 100 | //! 101 | //! Running [`Request::get`] allows to construct a GET request to a specific 102 | //! URL. Header keys can be specified by calling the 103 | //! [`with_header`](request::Request::with_header) method. 104 | //! A body can be specified by calling the 105 | //! [`with_body`](request::Request::with_body) method, which 106 | //! allows to add a body. 107 | //! 108 | //! ```rust 109 | //! use restest::{path, Request}; 110 | //! 111 | //! let request = Request::get(path!["users", "scrabsha"]) 112 | //! .with_header("token", "mom-said-yes"); 113 | //! ``` 114 | //! 115 | //! Similarly, POST requests can be creating by using [`Request::post`] instead 116 | //! of [`Request::get`]. The same is true for PUT and DELETE requests. 117 | //! 118 | //! # Performing the request 119 | //! 120 | //! Once that the [`Request`] object has been created, we can run the request 121 | //! by passing the [`Request`] to the [`Context`] when calling [`Context::run`]. 122 | //! Once `await`-ed, the [`expect_status`](request::RequestResult::expect_status) method 123 | //! checks for the request status code and converts the response body to the 124 | //! expected output type. 125 | //! 126 | //! ```rust,no_run 127 | //! use http::StatusCode; 128 | //! use uuid::Uuid; 129 | //! use serde::Deserialize; 130 | //! 131 | //! # use restest::{Context, Request}; 132 | //! # const CONTEXT: Context = Context::new(); 133 | //! # #[tokio::main] 134 | //! # async fn main() { 135 | //! # let request = Request::get("foo"); 136 | //! let user: User = CONTEXT 137 | //! .run(request) 138 | //! .await 139 | //! .expect_status(StatusCode::OK) 140 | //! .await; 141 | //! # } 142 | //! 143 | //! #[derive(Deserialize)] 144 | //! struct User { 145 | //! name: String, 146 | //! age: u8, 147 | //! id: Uuid, 148 | //! } 149 | //! ``` 150 | //! 151 | //! # Checking the response body 152 | //! 153 | //! Properties about the response body can be asserted with 154 | //! [`assert_body_matches`]. The macro supports the full rust pattern syntax, 155 | //! making it easy to check for expected values and variants. It also provides 156 | //! bindings, allowing you to bring data from the body in scope: 157 | //! 158 | //! ```rust 159 | //! use restest::assert_body_matches; 160 | //! # use uuid::Uuid; 161 | //! 162 | //! # let user = User { 163 | //! # name: "Grace Hopper".to_string(), 164 | //! # age: 85, 165 | //! # id: Uuid::new_v4(), 166 | //! # }; 167 | //! # 168 | //! assert_body_matches! { 169 | //! user, 170 | //! User { 171 | //! name: "Grace Hopper", 172 | //! age: 85, 173 | //! id, 174 | //! }, 175 | //! } 176 | //! 177 | //! // id is now a variable that can be used: 178 | //! println!("Grace Hopper has id `{}`", id); 179 | //! # 180 | //! # #[derive(serde::Deserialize)] 181 | //! # struct User { 182 | //! # name: String, 183 | //! # age: u8, 184 | //! # id: Uuid, 185 | //! # } 186 | //! ``` 187 | //! 188 | //! The extracted variable can be used for next requests or more complex 189 | //! testing. 190 | //! 191 | //! *And that's it!* 192 | 193 | /// Asserts that a response body matches a given pattern, adds 194 | /// bindings to the current scope. 195 | /// 196 | /// This pattern supports all the Rust pattern syntax, with a few additions: 197 | /// - matching on [`String`] can be done with string literals, 198 | /// - matching on [`Vec`] can be done using slice patterns, 199 | /// - values that are bound to variables are available in the whole scope, 200 | /// allowing for later use. 201 | /// 202 | /// # Panics 203 | /// 204 | /// This macro will panic if the body does not match the provided pattern. 205 | /// 206 | /// # Example 207 | /// 208 | /// The following code demonstrate basic matching: 209 | /// 210 | /// ```rust,no_run 211 | /// use restest::assert_body_matches; 212 | /// 213 | /// struct User { 214 | /// name: String, 215 | /// age: u8, 216 | /// } 217 | /// 218 | /// let user = get_user(); 219 | /// 220 | /// assert_body_matches! { 221 | /// user, 222 | /// User { 223 | /// name: "John Doe", 224 | /// age: 48, 225 | /// }, 226 | /// } 227 | /// 228 | /// fn get_user() -> User { 229 | /// /* Obscure code */ 230 | /// # User { 231 | /// # name: "John Doe".to_string(), 232 | /// # age: 48, 233 | /// # } 234 | /// } 235 | /// ``` 236 | /// 237 | /// Values can be brought to scope: 238 | /// 239 | /// ```rust 240 | /// use restest::assert_body_matches; 241 | /// use uuid::Uuid; 242 | /// 243 | /// struct User { 244 | /// id: Uuid, 245 | /// name: String, 246 | /// } 247 | /// 248 | /// let user = get_user(); 249 | /// 250 | /// assert_body_matches! { 251 | /// user, 252 | /// User { 253 | /// id, 254 | /// name: "John Doe", 255 | /// }, 256 | /// } 257 | /// 258 | /// // id is now available: 259 | /// println!("John Doe has id `{}`", id); 260 | /// 261 | /// fn get_user() -> User { 262 | /// /* Obscure code */ 263 | /// # User { 264 | /// # id: Uuid::new_v4(), 265 | /// # name: "John Doe".to_string(), 266 | /// # } 267 | /// } 268 | /// ``` 269 | /// 270 | /// Bringing values to scope may allow to extract information that are required 271 | /// to perform a next request. 272 | pub use restest_macros::assert_body_matches; 273 | 274 | pub mod context; 275 | pub mod request; 276 | mod url; 277 | 278 | pub use context::Context; 279 | pub use request::Request; 280 | 281 | /// Creates a path from multiple segments. 282 | /// 283 | /// All the segments don't need to have the same type. They all need to 284 | /// implement [`ToString`]. 285 | /// 286 | /// # Example 287 | /// 288 | /// ```rust 289 | /// use restest::{path, Request}; 290 | /// use uuid::Uuid; 291 | /// 292 | /// let my_user_id = Uuid::new_v4(); 293 | /// 294 | /// Request::get(path!["users", my_user_id]) 295 | /// // the rest of the request 296 | /// # ; 297 | /// ``` 298 | #[macro_export] 299 | macro_rules! path { 300 | ( $( $segment:expr ),* $(,)? ) => { 301 | vec![ $( Box::new($segment) as Box, )* ] 302 | }; 303 | } 304 | -------------------------------------------------------------------------------- /src/request.rs: -------------------------------------------------------------------------------- 1 | //! The various states of a request. 2 | //! 3 | //! A request has a specific lifecycle: 4 | //! - a [`Request`] is created using one of [`Request::get`], 5 | //! [`Request::post`] and so on, 6 | //! - the request can be modified using the different methods un [`Request`], such as 7 | //! [`with_body`](Request::with_body) or [`with_header`](Request::with_header), 8 | //! - the [`Request`] is passed as argument of 9 | //! [`Context::run`](crate::Context), returning a [`RequestResult`], 10 | //! - the final request body is constructed by calling 11 | //! [`expect_status`](RequestResult::expect_status). 12 | //! 13 | //! The documentation for [`Request`] provide more specific description. 14 | 15 | use core::panic; 16 | use std::collections::HashMap; 17 | 18 | use http::status::StatusCode; 19 | use reqwest::Response; 20 | use serde::{de::DeserializeOwned, Serialize}; 21 | 22 | use crate::url::IntoUrl; 23 | 24 | /// An HTTP request we're about to run. 25 | /// 26 | /// # Creating a request 27 | /// 28 | /// First a [`Request`] must be created. This object will allow to 29 | /// encode the request information. It can be created with [`Request::get`] 30 | /// or [`Request::post`], depending on the kind of request needed. 31 | /// 32 | /// Then, various request metadata can be encoded in the builder once it is 33 | /// created. For instance, one can use the 34 | /// [`with_header`](Request::with_header) method to specify a header key 35 | /// to the request. 36 | /// 37 | /// Once the metadata is encoded, the [`with_body`](Request::with_body) 38 | /// method allows to specify a body. 39 | /// 40 | /// The following code snippet shows all these three steps: 41 | /// 42 | /// ```rust 43 | /// use restest::Request; 44 | /// 45 | /// use serde::Serialize; 46 | /// 47 | /// let request = Request::get("users") // Creating the builder... 48 | /// .with_header("token", "mom-said-yes") // ... Adding metadata to the builder 49 | /// .with_body(GetUsersFilter::All); // ... Adding a body 50 | /// 51 | /// #[derive(Serialize)] 52 | /// enum GetUsersFilter { 53 | /// All, 54 | /// YoungerThan(u8), 55 | /// OlderThan(u8), 56 | /// } 57 | /// ``` 58 | /// 59 | /// # Running a request 60 | /// 61 | /// Once the [`Request`] has been successfully created, it can be run by using 62 | /// the [`Context::run`](crate::Context::run) method. 63 | pub struct Request 64 | where 65 | B: Serialize, 66 | { 67 | pub(crate) body: B, 68 | pub(crate) header: HashMap, 69 | pub(crate) method: Method, 70 | pub(crate) url: String, 71 | pub(crate) context_description: String, 72 | } 73 | 74 | impl Request<()> { 75 | /// Creates a GET request builder for a specific URL. 76 | /// 77 | /// # Specifying an URL 78 | /// 79 | /// The url argument must be either a string literal or the value produced 80 | /// by the [`path`] macro. Only the absolute path to the resource must be 81 | /// passed. 82 | /// 83 | /// # Example 84 | /// 85 | /// ```rust 86 | /// use restest::{path, Request}; 87 | /// 88 | /// let request_1 = Request::get("users"); 89 | /// 90 | /// let user_name = "scrabsha"; 91 | /// let request_2 = Request::get(path!["users", user_name]); 92 | /// ``` 93 | pub fn get(url: impl IntoUrl) -> Request<()> { 94 | let url = url.into_url(); 95 | Request { 96 | body: (), 97 | header: HashMap::new(), 98 | method: Method::Get, 99 | context_description: format!("GET:{}", url), 100 | url, 101 | } 102 | } 103 | 104 | /// Creates a POST request builder for a specific URL. 105 | /// 106 | /// # Specifying an URL 107 | /// 108 | /// The url argument must be either a string literal or the value produced 109 | /// by the [`path`] macro. Only the absolute path to the resource must be 110 | /// passed. 111 | /// 112 | /// Refer to the [`get`][Request::get] method documentation for a 113 | /// self-describing example. 114 | pub fn post(url: impl IntoUrl) -> Request<()> { 115 | let url = url.into_url(); 116 | Request { 117 | body: (), 118 | header: HashMap::new(), 119 | method: Method::Post, 120 | context_description: format!("POST:{}", url), 121 | url, 122 | } 123 | } 124 | 125 | /// Creates a PUT request builder for a specific URL. 126 | /// 127 | /// # Specifying an URL 128 | /// 129 | /// The url argument must be either a string literal or the value produced 130 | /// by the [`path`] macro. Only the absolute path to the resource must be 131 | /// passed. 132 | /// 133 | /// Refer to the [`get`][Request::get] method documentation for a 134 | /// self-describing example. 135 | pub fn put(url: impl IntoUrl) -> Request<()> { 136 | let url = url.into_url(); 137 | Request { 138 | body: (), 139 | header: HashMap::new(), 140 | method: Method::Put, 141 | context_description: format!("PUT:{}", url), 142 | url, 143 | } 144 | } 145 | 146 | /// Creates a DELETE request builder for a specific URL. 147 | /// 148 | /// # Specifying an URL 149 | /// 150 | /// The url argument must be either a string literal or the value produced 151 | /// by the [`path`] macro. Only the absolute path to the resource must be 152 | /// passed. 153 | /// 154 | /// Refer to the [`get`][Request::get] method documentation for a 155 | /// self-describing example,. 156 | pub fn delete(url: impl IntoUrl) -> Request<()> { 157 | let url = url.into_url(); 158 | Request { 159 | body: (), 160 | header: HashMap::new(), 161 | method: Method::Delete, 162 | context_description: format!("DELETE:{}", url), 163 | url, 164 | } 165 | } 166 | } 167 | 168 | /// Allows encode metadata in order to create a [`Request`]. 169 | /// 170 | /// This type can be created by calling either [`Request::get`], 171 | /// [`Request::post`], [`Request::put`] or [`Request::delete`]. 172 | /// Specifically, this type allows to encode the request 173 | /// header with [`Request::with_header`], and to encode the 174 | /// request body with [`Request::with_body`]. 175 | /// 176 | /// This allows to create [`Request`] types, as shown in the following example: 177 | /// 178 | /// ```rust 179 | /// use restest::Request; 180 | /// 181 | /// use serde::Serialize; 182 | /// 183 | /// let request = Request::get("user") 184 | /// .with_header("token", "mom-said-yes") 185 | /// .with_body(GetUserRequest { 186 | /// login: String::from("jdoe") 187 | /// }); 188 | /// 189 | /// #[derive(Serialize)] 190 | /// struct GetUserRequest { 191 | /// login: String, 192 | /// } 193 | /// ``` 194 | impl Request 195 | where 196 | B: Serialize, 197 | { 198 | /// Adds a header key and value to the request. 199 | pub fn with_header(mut self, key: impl ToString, value: impl ToString) -> Request { 200 | let previous_entry = self.header.insert(key.to_string(), value.to_string()); 201 | assert!(previous_entry.is_none(), "Attempt to replace a header"); 202 | 203 | self 204 | } 205 | 206 | /// Specifies a body, returns the final [`Request`] object. 207 | pub fn with_body(self, body: C) -> Request 208 | where 209 | C: Serialize, 210 | { 211 | let Request { 212 | header, 213 | method, 214 | url, 215 | context_description, 216 | .. 217 | } = self; 218 | 219 | Request { 220 | body, 221 | header, 222 | method, 223 | url, 224 | context_description, 225 | } 226 | } 227 | 228 | /// Specifies a context description. Returns the final [`Request`] object. 229 | pub fn with_context(mut self, context_description: impl ToString) -> Request { 230 | self.context_description = context_description.to_string(); 231 | 232 | self 233 | } 234 | } 235 | 236 | impl AsRef> for Request 237 | where 238 | B: Serialize, 239 | { 240 | fn as_ref(&self) -> &Request { 241 | self 242 | } 243 | } 244 | 245 | impl Clone for Request 246 | where 247 | B: Serialize + Clone, 248 | { 249 | fn clone(&self) -> Request { 250 | Request { 251 | body: self.body.clone(), 252 | header: self.header.clone(), 253 | method: self.method, 254 | url: self.url.clone(), 255 | context_description: self.context_description.clone(), 256 | } 257 | } 258 | } 259 | 260 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 261 | pub(crate) enum Method { 262 | Get, 263 | Post, 264 | Put, 265 | Delete, 266 | } 267 | 268 | /// The data returned by the server once the request is performed. 269 | /// 270 | /// This datatype is meant for intermediary representation. It can be converted 271 | /// to a concrete type by calling [`RequestResult::expect_status`]. 272 | pub struct RequestResult { 273 | pub(crate) response: Response, 274 | pub(crate) context_description: String, 275 | } 276 | 277 | impl RequestResult { 278 | /// Checks if the response status meets an expected status code and convert 279 | /// the body to a concrete type. 280 | /// 281 | /// This method uses `serde` internally, so the output type must implement 282 | /// [`DeserializeOwned`]. 283 | /// 284 | /// # Panics 285 | /// 286 | /// This method panics if the server response status is not equal to 287 | /// `status` or if the body can not be deserialized to the specified type. 288 | #[track_caller] 289 | pub async fn expect_status(self, status: StatusCode) -> T 290 | where 291 | T: DeserializeOwned, 292 | { 293 | match self.ensure_status(status).await { 294 | Ok(deserialized) => deserialized, 295 | Err(err) => panic!("{}", err), 296 | } 297 | } 298 | 299 | /// Checks if the response status meets an expected status code and convert 300 | /// the body to a concrete type. 301 | /// 302 | /// This method uses `serde` internally, so the output type must implement 303 | /// [`DeserializeOwned`]. 304 | /// 305 | /// # Error 306 | /// 307 | /// This method return an error if the server response status is not equal to 308 | /// `status` or if the body can not be deserialized to the specified type. 309 | #[track_caller] 310 | pub async fn ensure_status(self, status: StatusCode) -> Result 311 | where 312 | T: DeserializeOwned, 313 | { 314 | if self.response.status() != status { 315 | return Err(format!("Unexpected server response code for request '{}'. Body is {}", 316 | self.context_description, 317 | self.response.text().await.map_err( 318 | |err| { 319 | format!("Unexpected server response code for request {} : {}. Unable to read response body",self.context_description, err) 320 | } 321 | )?)); 322 | } 323 | 324 | self.response.json().await.map_err(|err| { 325 | format!( 326 | "Failed to deserialize body for request '{}': {}", 327 | self.context_description, err 328 | ) 329 | }) 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/url.rs: -------------------------------------------------------------------------------- 1 | pub trait IntoUrl { 2 | fn into_url(self) -> String; 3 | } 4 | 5 | impl IntoUrl for &'static str { 6 | fn into_url(self) -> String { 7 | if self.starts_with('/') { 8 | self.to_string() 9 | } else { 10 | format!("/{}", self) 11 | } 12 | } 13 | } 14 | 15 | impl IntoUrl for Vec> { 16 | fn into_url(self) -> String { 17 | let mut buff = String::new(); 18 | 19 | for segment in self { 20 | buff.push('/'); 21 | let segment = segment.to_string(); 22 | buff.push_str(segment.as_str()); 23 | } 24 | 25 | buff 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/ok/json_pattern_array.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | restest::assert_body_matches!(vec![42, 41], [42, 41]); 3 | 4 | restest::assert_body_matches! { 5 | vec![42, 101], 6 | a 7 | }; 8 | 9 | assert_eq!(a, [42, 101]); 10 | 11 | restest::assert_body_matches! { 12 | vec![101, 42], 13 | [a, 42], 14 | }; 15 | 16 | assert_eq!(a, 101); 17 | 18 | restest::assert_body_matches! { 19 | vec![101, 102], 20 | [a, _], 21 | }; 22 | 23 | assert_eq!(a, 101); 24 | } 25 | -------------------------------------------------------------------------------- /tests/ok/json_pattern_binding.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | restest::assert_body_matches! { 3 | 42 4 | , 5 | val 6 | }; 7 | 8 | // val is in scope 9 | assert_eq!(val, 42isize); 10 | 11 | restest::assert_body_matches! { 12 | [42] 13 | , 14 | [val_] 15 | }; 16 | 17 | // val_ is in scope 18 | assert_eq!(val_, 42isize); 19 | 20 | restest::assert_body_matches! { 21 | 42 22 | , 23 | val__ 24 | }; 25 | 26 | assert_eq!(val__, 42u8); 27 | } 28 | -------------------------------------------------------------------------------- /tests/ok/json_pattern_number.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | restest::assert_body_matches!(42, 42); 3 | } 4 | -------------------------------------------------------------------------------- /tests/setup.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | fn ui() { 3 | let t = trybuild::TestCases::new(); 4 | t.pass("tests/ok/*.rs"); 5 | t.compile_fail("tests/err/*.rs"); 6 | } 7 | --------------------------------------------------------------------------------