├── .cargo └── config.toml ├── .config └── nextest.toml ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── audit.yml │ ├── book.yml │ ├── ci.yml │ └── links.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── Makefile.toml ├── README.md ├── book ├── .gitignore ├── book.toml └── src │ ├── SUMMARY.md │ ├── img │ └── owlduty_logo.jpg │ ├── introduction.md │ ├── logs.md │ ├── quickstart.md │ └── writing_tests │ ├── assertions.md │ ├── client_configuration.md │ ├── index.md │ └── requests.md ├── src ├── assert.rs ├── assertion │ ├── impls │ │ ├── header.rs │ │ ├── json_body.rs │ │ ├── json_path.rs │ │ ├── mod.rs │ │ ├── status.rs │ │ └── time.rs │ ├── mod.rs │ └── traits.rs ├── dsl │ ├── expression.rs │ ├── http │ │ ├── body.rs │ │ ├── header.rs │ │ ├── headers.rs │ │ ├── mod.rs │ │ ├── status.rs │ │ └── time.rs │ ├── json_path.rs │ ├── mod.rs │ └── part.rs ├── error.rs ├── grillon.rs ├── lib.rs ├── request.rs ├── response.rs └── url.rs └── tests ├── assert ├── assert_fn.rs ├── auth.rs ├── cookies.rs ├── headers.rs ├── json_body.rs ├── json_path.rs ├── json_schema.rs ├── mod.rs ├── response_time.rs ├── status.rs └── surf_impl.rs ├── fixtures ├── inexistant_order.json ├── json_body.json ├── order4.json ├── orders.json ├── orders_schema.json ├── user_id_schema.json └── user_schema.json ├── http ├── basic_http.rs ├── https.rs └── mod.rs ├── http_mock_server.rs └── lib.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | docs = "make doc" 3 | lint = "make lint" 4 | book = "make book" 5 | -------------------------------------------------------------------------------- /.config/nextest.toml: -------------------------------------------------------------------------------- 1 | [profile.ci] 2 | failure-output = "immediate-final" 3 | # Don't fail fast in CI to run the full test suite. 4 | fail-fast = false 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: theredfish 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | on: 4 | push: 5 | paths: 6 | - "**/Cargo.toml" 7 | - "**/Cargo.lock" 8 | schedule: 9 | - cron: "0 9 * * *" 10 | 11 | jobs: 12 | audit: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions-rs/audit-check@v1 17 | with: 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/book.yml: -------------------------------------------------------------------------------- 1 | name: Book gh-pages 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: ["main"] 7 | tags: ["v*"] 8 | paths: ["book/**"] 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-20.04 13 | env: 14 | BOOK_VERSION: ${{ (startsWith(github.ref, 'refs/tags/v') && 'current') || 'dev' }} 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Setup mdBook 21 | uses: peaceiris/actions-mdbook@v1 22 | with: 23 | mdbook-version: "0.4.25" 24 | 25 | - run: mdbook build book -d version/$BOOK_VERSION 26 | 27 | - name: Deploy book 28 | uses: peaceiris/actions-gh-pages@v3 29 | with: 30 | keep_files: true 31 | github_token: ${{ secrets.GITHUB_TOKEN }} 32 | publish_dir: ./book/version 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Rust CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | tags: ["v*"] 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | 10 | jobs: 11 | rustfmt: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions-rs/toolchain@v1 16 | with: 17 | profile: minimal 18 | toolchain: stable 19 | components: rustfmt 20 | - run: cargo fmt --all -- --check 21 | 22 | clippy: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: actions-rs/toolchain@v1 27 | with: 28 | profile: minimal 29 | toolchain: stable 30 | components: clippy 31 | - run: cargo clippy --workspace --tests --all-features -- -D warnings 32 | 33 | test: 34 | runs-on: ubuntu-latest 35 | env: 36 | CARGO_TERM_COLOR: always 37 | steps: 38 | - uses: actions/checkout@v3 39 | - uses: taiki-e/install-action@v2 40 | with: 41 | tool: nextest 42 | - run: cargo nextest run --all-features --profile ci 43 | 44 | doc: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v3 48 | - uses: actions-rs/toolchain@v1 49 | with: 50 | profile: minimal 51 | toolchain: stable 52 | - run: cargo doc --all-features --no-deps 53 | 54 | deploy-crates-io: 55 | name: Release on crates.io 56 | needs: 57 | - rustfmt 58 | - clippy 59 | - test 60 | - doc 61 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v3 65 | - run: cargo publish -p grillon --token ${{ secrets.CRATES_IO }} 66 | -------------------------------------------------------------------------------- /.github/workflows/links.yml: -------------------------------------------------------------------------------- 1 | name: Links checker 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | tags: ["v*"] 7 | pull_request: 8 | 9 | jobs: 10 | linkChecker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Restore lychee cache 16 | uses: actions/cache@v3 17 | with: 18 | path: .lycheecache 19 | key: cache-lychee-${{ github.sha }} 20 | restore-keys: cache-lychee- 21 | 22 | - name: Link Checker 23 | uses: lycheeverse/lychee-action@v1.8.0 24 | with: 25 | fail: true 26 | args: "--cache --max-cache-age 1d ." 27 | env: 28 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # `grillon` changelog 2 | 3 | ## [v0.6.0] - 2024-12-07 4 | 5 | - [Diff](/../../compare/v0.5.0...v0.6.0) 6 | - [Milestone](/../../milestone/4) 7 | 8 | [v0.6.0]: /../../tree/v0.6.0 9 | 10 | ### Added 11 | 12 | - Add `json_path` matching capabilities ([#87] [#88]) 13 | - Add built-in authentication methods `basic_auth` and `bearer_auth` ([#37], [#89]) 14 | 15 | [#37]: /../../issues/37 16 | [#87]: /../../issues/87 17 | [#88]: /../../pull/88 18 | [#89]: /../../pull/89 19 | 20 | ## [v0.5.0] - 2024-10-27 21 | 22 | - [Diff](/../../compare/v0.4.0...v0.5.0) 23 | - [Milestone](/../../milestone/3) 24 | 25 | [v0.5.0]: /../../tree/v0.5.0 26 | 27 | ### Breaking Changes 28 | 29 | - Replace Hyper by Reqwest for the default blanket implementation ([#63], [#73]) 30 | 31 | ### Changed 32 | 33 | - Expansion of the book documentation ([#42]) 34 | 35 | ### Added 36 | 37 | - `Equality` json path assertions ([#34]) 38 | - Built-in json schema assertion ([#9], [#48], [#50], [#51], [#55]) 39 | - Extend json path assertions with `contains` and `does_not_contain` ([#40], [#75]) 40 | - Support string literals for the `headers` built-in functions ([#33], [#78]) 41 | - Built-in single header assertion ([#82]) 42 | - Add optional cookie store to the http client ([#54], [#77]) 43 | - Add static links checker ([#43]) 44 | 45 | [#9]: /../../issues/9 46 | [#33]: /../../issues/33 47 | [#34]: /../../pull/34 48 | [#40]: /../../issues/40 49 | [#42]: /../../pull/42 50 | [#43]: /../../pull/43 51 | [#48]: /../../pull/48 52 | [#50]: /../../pull/50 53 | [#51]: /../../issues/51 54 | [#54]: /../../issues/54 55 | [#55]: /../../pull/55 56 | [#63]: /../../issues/63 57 | [#73]: /../../issues/73 58 | [#75]: /../../pull/75 59 | [#77]: /../../pull/77 60 | [#78]: /../../pull/78 61 | [#82]: /../../pull/82 62 | 63 | ## [0.4.0] - 2023-01-26 64 | 65 | [0.4.0]: /../../tree/v0.4.0 66 | 67 | - [Diff](/../../compare/v0.3.0...v0.4.0) 68 | 69 | ### Changed 70 | 71 | - Complete rewrite of the assertion logic ([#22]) 72 | - Enhancement of the http matchers as part of the DSL ([#17], [#18], [#23]) 73 | - Simplified CI file and updated github actions ([#27]) 74 | 75 | ### Added 76 | 77 | - Domain specific language for matching operators ([#20]) 78 | - Http response time matcher ([#19]) 79 | - Dependabot to manage dependency updates ([#30]) 80 | - Grillon book with mdbook and github actions ([#28]) 81 | 82 | [#17]: /../../issues/17 83 | [#18]: /../../issues/18 84 | [#19]: /../../issues/19 85 | [#20]: /../../pull/20 86 | [#22]: /../../pull/22 87 | [#23]: /../../pull/23 88 | [#27]: /../../issues/27 89 | [#28]: /../../issues/28 90 | [#30]: /../../issues/30 91 | 92 | ## [0.3.0] - 2022-01-25 93 | 94 | [0.3.0]: /../../tree/v0.3.0 95 | 96 | - [Diff](/../../compare/v0.2.0...v0.3.0) 97 | 98 | ### Changed 99 | 100 | - `Response::json` has now a return type following the standard for `async` functions bounded by the 101 | lifetime of their arguments. Now the function should also be compatible with `async_trait` without `Send` requirement. 102 | ([#16]) 103 | 104 | [#16]: /../../pull/16 105 | 106 | ## [0.2.0] - 2022-01-22 107 | 108 | [0.2.0]: /../../tree/v0.2.0 109 | 110 | - [Diff](/../../compare/v0.1.0...v0.2.0) 111 | - [Milestone](/../../milestone/1) 112 | 113 | ### Added 114 | 115 | - Built-in HTTP functions : `HEAD`, `OPTIONS` and `CONNECT`. ([#8], [#12]) 116 | - `Assert::assert_fn` to extend built-in assertions with a custom assertion. ([#7], [#13], [#14] [#15]) 117 | 118 | ### Changed 119 | 120 | - `Assert` fields (`json`, `headers`, `status`) are now public to allow external access. ([#15]) 121 | 122 | [#7]: /../../issues/7 123 | [#8]: /../../issues/8 124 | [#13]: /../../issues/13 125 | [#12]: /../../pull/12 126 | [#14]: /../../pull/14 127 | [#15]: /../../pull/15 128 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "grillon" 3 | version = "0.6.0" 4 | authors = ["theredfish "] 5 | description = "Grillon offers an elegant and natural way to approach API testing in Rust." 6 | repository = "https://github.com/theredfish/grillon" 7 | keywords = ["test", "http", "api", "e2e"] 8 | categories = ["development-tools::testing"] 9 | readme = "README.md" 10 | license = "MIT OR Apache-2.0" 11 | include = ["/src", "LICENSE*", "README.md"] 12 | edition = "2021" 13 | 14 | [dependencies] 15 | serde = { version = "1.0.215", features = ["derive"] } 16 | serde_json = "1.0.133" 17 | http = "1.2.0" 18 | url = "2.5.4" 19 | futures = "0.3.31" 20 | strum = { version = "0.27.0", features = ["derive"] } 21 | strum_macros = "0.27.0" 22 | jsonpath-rust = "0.7.3" 23 | jsonschema = "0.30.0" 24 | reqwest = { version = "0.12.9", features = ["json", "cookies"] } 25 | thiserror = "2.0.4" 26 | regex = "1.11.1" 27 | 28 | [dev-dependencies] 29 | tokio = { version = "1.42.0", features = ["macros"] } 30 | httpmock = "0.7.0" 31 | async-trait = "0.1.83" 32 | test-case = "3.3.1" 33 | surf = "2.3.2" 34 | base64 = "0.22.1" 35 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Julian Didier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile.toml: -------------------------------------------------------------------------------- 1 | [tasks.book] 2 | command = "mdbook" 3 | args = ["serve", "book", "--open"] 4 | 5 | [tasks.doc] 6 | command = "cargo" 7 | args = ["watch", "-x", "doc --all-features --no-deps --open"] 8 | 9 | [tasks.lint] 10 | command = "cargo" 11 | args = ["clippy", "--workspace", "--all-features", "--", "-D", "warnings"] 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grillon 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/grillon)](https://crates.io/crates/grillon) 4 | [![docs.rs](https://img.shields.io/docsrs/grillon)](https://docs.rs/grillon/latest/grillon) 5 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/theredfish/grillon/ci.yml) 6 | [![Check Links](https://github.com/theredfish/grillon/actions/workflows/links.yml/badge.svg)](https://github.com/theredfish/grillon/actions/workflows/links.yml) 7 | 8 | Grillon offers an elegant and natural way to approach API testing in Rust. 9 | 10 | - Elegant, intuitive and expressive API 11 | - Built-in testing functions 12 | - Extensible 13 | 14 | > Please note that the API is subject to a lot of changes until the `v1.0.0`. 15 | 16 | ## Documentation 17 | 18 | - Book ([current](https://theredfish.github.io/grillon/current) | [dev](https://theredfish.github.io/grillon/dev)) 19 | - [API doc](https://docs.rs/grillon/latest/grillon) 20 | - [Changelog](https://github.com/theredfish/grillon/blob/main/CHANGELOG.md) 21 | 22 | ## Getting started 23 | 24 | > Before you begin, be sure to read the book to learn more about configuring logs and assertions! 25 | 26 | You need [Tokio](https://tokio.rs/) as asynchronous runtime. Generally, testing libs are 27 | used in unit or integration tests so let's declare `grillon` as a dev-dependency. 28 | 29 | Add `grillon` to `Cargo.toml` 30 | 31 | ```toml 32 | [dev-dependencies] 33 | grillon = "0.6.0" 34 | tokio = { version = "1", features = ["macros"] } 35 | ``` 36 | 37 | Then use `grillon` : 38 | 39 | ```rust 40 | use grillon::{dsl::*, dsl::http::*, json, Grillon, StatusCode, Result}; 41 | use grillon::header::{HeaderValue, CONTENT_LENGTH, CONTENT_TYPE}; 42 | use grillon::Assert; 43 | 44 | #[tokio::test] 45 | async fn end_to_end_test() -> Result<()> { 46 | Grillon::new("https://jsonplaceholder.typicode.com")? 47 | .post("posts") 48 | .payload(json!({ 49 | "title": "foo", 50 | "body": "bar", 51 | "userId": 1 52 | })) 53 | .assert() 54 | .await 55 | .status(is_success()) 56 | .status(is(201)) 57 | .response_time(is_less_than(700)) 58 | .json_body(is(json!({ 59 | "id": 101, 60 | }))) 61 | .json_body(schema(json!({ 62 | "properties": { 63 | "id": { "type": "number" } 64 | } 65 | }))) 66 | .json_path("$.id", is(json!(101))) 67 | .header(CONTENT_TYPE, is("application/json; charset=utf-8")) 68 | .headers(contains(vec![ 69 | ( 70 | CONTENT_TYPE, 71 | HeaderValue::from_static("application/json; charset=utf-8"), 72 | ), 73 | (CONTENT_LENGTH, HeaderValue::from_static("15")), 74 | ])) 75 | .assert_fn(|assert| { 76 | let Assert { 77 | headers, 78 | status, 79 | json, 80 | .. 81 | } = assert.clone(); 82 | 83 | assert!(!headers.unwrap().is_empty()); 84 | assert!(status.unwrap() == StatusCode::CREATED); 85 | assert!(json.is_some()); 86 | 87 | println!("Json response : {:#?}", assert.json); 88 | }); 89 | 90 | Ok(()) 91 | } 92 | 93 | ``` 94 | -------------------------------------------------------------------------------- /book/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /book/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["theredfish"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "Grillon - User Guide" 7 | 8 | [output.html] 9 | git-repository-url = "https://github.com/theredfish/grillon" 10 | 11 | [output.html.playground] 12 | runnable = false 13 | 14 | [rust] 15 | edition = "2021" 16 | -------------------------------------------------------------------------------- /book/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Introduction](./introduction.md) 4 | - [Quickstart](./quickstart.md) 5 | - [Writing tests](./writing_tests/index.md) 6 | - [Client configuration](./writing_tests/client_configuration.md) 7 | - [Requests](./writing_tests/requests.md) 8 | - [Assertions](./writing_tests/assertions.md) 9 | - [Logs](./logs.md) 10 | -------------------------------------------------------------------------------- /book/src/img/owlduty_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theredfish/grillon/6b266c7720e63d50394ef3632a0467b101aa0cb5/book/src/img/owlduty_logo.jpg -------------------------------------------------------------------------------- /book/src/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Grillon is a Rust library offering an elegant and natural way to approach API testing in Rust. 4 | 5 | - Elegant, intuitive and expressive API 6 | - Built-in testing functions 7 | - Extensible 8 | 9 | Check out our [Quickstart](./quickstart.md). 10 | 11 | ## Usage 12 | 13 | As the library is flexible, you can easily integrate it into your testing strategy in a Rust project. 14 | You can use it for synthetic monitoring, endpoint monitoring, functional testing, integration 15 | testing, BDD testing (e.g [cucumber-rs](https://github.com/cucumber-rs/cucumber)), ... it's up to 16 | you. Grillon does not impose any test strategy or organization. 17 | 18 | Depending on how you configure your [logs](logs.md), the execution will fail-fast or not and can be 19 | formatted in a human-readable or json output. 20 | 21 | ## Next big steps 22 | 23 | Here is an unordered and non-exhaustive list of what is planned for Grillon next: 24 | 25 | - Improve HTTP testing: HTTP/1.1 + HTTP/2, json path, xpath, form-data 26 | - Extend testing capabilities per-protocol/framework 27 | - WebSocket 28 | - gRPC 29 | - SSL 30 | - TCP, UDP, DNS, ICMP 31 | - Logs and metrics 32 | - Support for YAML-formatted (or other formats) tests to extend the library outside of Rust projects 33 | -------------------------------------------------------------------------------- /book/src/logs.md: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | Grillon provides a `LogSettings` structure so you can easily configure how the assertion results 4 | should be output. The default log settings are set to `StdAssert`. Only failures will be printed 5 | to standard output in a human-readable format. 6 | 7 | Each assertion results in a log to standard output that you can connect with your infrastructure to 8 | react to specific events. We could imagine for example an integration with CloudWatch and create an 9 | alert as soon as the json log contains the key/value `"result": "failure"`. 10 | 11 | ## Human readable 12 | 13 | ### Failures only 14 | 15 | This is the default, fail-fast, mode. As soon as you get a failure, the execution halts. 16 | 17 | ```rust 18 | Grillon::new("https://jsonplaceholder.typicode.com")? 19 | .log_settings(LogSettings::StdAssert) 20 | .get("posts?id=1") 21 | .assert() 22 | .await 23 | .status(is_client_error()); 24 | ``` 25 | 26 | As the status isn't a client error but a successful code, the assertion fails. The following logs 27 | will be printed on the standard output: 28 | 29 | ```bash 30 | part: status code 31 | should be between: "400 and 499" 32 | was: "200" 33 | ``` 34 | 35 | If you replace `is_client_error()` by `is_success()` you should now see a successful test without 36 | any logs. 37 | 38 | ### Failures and successes 39 | 40 | Now, if you want to log everything, even passing test cases (when debugging for example), then you 41 | just need to change your log settings to `StdOut`: 42 | 43 | ```rust 44 | Grillon::new("https://jsonplaceholder.typicode.com")? 45 | .log_settings(LogSettings::StdAssert) 46 | .get("posts?id=1") 47 | .assert() 48 | .await 49 | .status(is_success()); 50 | ``` 51 | 52 | Which should produce similar output: 53 | 54 | ```bash 55 | running 1 test 56 | 57 | part: status code 58 | should be between: "200 and 299" 59 | test http::basic_http::test ... ok 60 | ``` 61 | 62 | ## Json 63 | 64 | The json format is to be used when you want to integrate external tools: CI/CD, logging services 65 | such as Elasticsearch or Cloudwatch, reporting tools, etc. 66 | 67 | ```rust 68 | Grillon::new("https://jsonplaceholder.typicode.com")? 69 | .log_settings(LogSettings::Json) 70 | .get("posts?id=1") 71 | .assert() 72 | .await 73 | .status(is_client_error()); 74 | ``` 75 | 76 | With the previous code block, we get an assertion failure since the status code isn't a client 77 | error. Here is the resulting json output (stdout) of the run: 78 | 79 | ```json 80 | { 81 | "left":200, 82 | "part":"status code", 83 | "predicate":"should be between", 84 | "result":"failed", 85 | "right":[ 86 | 400, 87 | 499 88 | ] 89 | } 90 | ``` 91 | 92 | Grillon doesn't provide any connectors yet, so you will need to redirect stdout logs to a driver if 93 | you want to ingest json logs with other services. 94 | -------------------------------------------------------------------------------- /book/src/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | Using Grillon is pretty straightforward, we will consider you are running it as part of your testing 4 | process. But you can also use it as a regular dependency. 5 | 6 | ## Configuration 7 | 8 | Before we begin, let's create a `tests/` directory at the root of the project. Create a file there 9 | named `create_posts.rs`. 10 | 11 | Add `grillon` to your development dependencies with `tokio`, as we need a runtime to run async 12 | functions in our test environement. 13 | 14 | ```toml 15 | [dev-dependencies] 16 | grillon = "0.6.0" 17 | tokio = { version = "1", features = ["macros"] } 18 | ``` 19 | 20 | Our example will test the `/posts` endpoint of `jsonplaceholder.typicode.com`. We will send a json 21 | payload and we will assert that our resource is correctly created with an acceptable response time 22 | (< 500 ms). Depending on your location, feel free to tweak the response time value 23 | (in milliseconds). 24 | 25 | ## Write the test 26 | 27 | Create a new `create_posts.rs` file in `tests` and copy/paste the following example: 28 | 29 | ```rust,noplaypen 30 | use grillon::{dsl::*, dsl::http::*, json, Grillon, StatusCode, Result}; 31 | use grillon::header::{HeaderValue, CONTENT_LENGTH, CONTENT_TYPE}; 32 | use grillon::Assert; 33 | 34 | #[tokio::test] 35 | async fn end_to_end_test() -> Result<()> { 36 | Grillon::new("https://jsonplaceholder.typicode.com")? 37 | .post("posts") 38 | .payload(json!({ 39 | "title": "foo", 40 | "body": "bar", 41 | "userId": 1 42 | })) 43 | .assert() 44 | .await 45 | .status(is_success()) 46 | .status(is(201)) 47 | .response_time(is_less_than(700)) 48 | .json_body(is(json!({ 49 | "id": 101, 50 | }))) 51 | .json_body(schema(json!({ 52 | "properties": { 53 | "id": { "type": "number" } 54 | } 55 | }))) 56 | .json_path("$.id", is(json!(101))) 57 | .header(CONTENT_TYPE, is("application/json; charset=utf-8")) 58 | .headers(contains(vec![ 59 | ( 60 | CONTENT_TYPE, 61 | HeaderValue::from_static("application/json; charset=utf-8"), 62 | ), 63 | (CONTENT_LENGTH, HeaderValue::from_static("15")), 64 | ])) 65 | .assert_fn(|assert| { 66 | let Assert { 67 | headers, 68 | status, 69 | json, 70 | .. 71 | } = assert.clone(); 72 | 73 | assert!(!headers.unwrap().is_empty()); 74 | assert!(status.unwrap() == StatusCode::CREATED); 75 | assert!(json.is_some()); 76 | 77 | println!("Json response : {:#?}", assert.json); 78 | }); 79 | 80 | Ok(()) 81 | } 82 | 83 | ``` 84 | 85 | ## Run the test 86 | 87 | ```bash 88 | cargo test --test create_posts -- --nocapture 89 | ``` 90 | 91 | You should see similar output: 92 | 93 | ```bash 94 | cargo test --test create_posts -- --nocapture 95 | Finished test [unoptimized + debuginfo] target(s) in 0.14s 96 | Running tests/create_posts.rs (target/debug/deps/create_posts-26c6ab07b039dabd) 97 | 98 | running 1 test 99 | Json response : Some( 100 | Object { 101 | "id": Number(101), 102 | }, 103 | ) 104 | test create_posts_monitoring ... ok 105 | 106 | test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.38s 107 | ``` 108 | 109 | Well done! You've written your first HTTP API test! 110 | 111 | In this example, we performed assertions on: 112 | 113 | - the status code 114 | - the response time 115 | - the headers 116 | - the entire json body 117 | 118 | We also added custom assertions and function calls with `assert_fn`. So if you have specific needs, 119 | you can manipulate `assert` and add your own logic! For more information, you can read more about 120 | [assertions](./writing_tests/assertions.md) in this book. 121 | 122 | ## Next steps 123 | 124 | This book contains more in-depth content about Grillon such as reusing a request builder, how to 125 | organize your tests, available assertions, and how to configure your log output. You can also find 126 | technical information in our [latest API documentation](https://docs.rs/grillon/latest/grillon/). 127 | -------------------------------------------------------------------------------- /book/src/writing_tests/assertions.md: -------------------------------------------------------------------------------- 1 | # Assertions 2 | 3 | Grillon provides domain-specific language per protocol and framework to make it natural to write 4 | assertions. 5 | 6 | An assertion is made up of: 7 | 8 | - A part under test like `status`, 9 | - a predicate such as `is`, `is_not`, 10 | - and an expected value, for example `200`. 11 | 12 | The predicates of a specific part can handle different type parameters. For example if you want to 13 | assert that a status code is 200, you can pass a `u16` or a `StatusCode`. This information is 14 | described below in the types column. 15 | 16 | ## Execution order 17 | 18 | Your assertions are executed sequentially and in a blocking fashion. Asynchronous executions are not 19 | supported yet. With sequential runs, order matters, so if you want to fail early under specific 20 | conditions, it's possible. Each assertion produces [logs](../logs.md). 21 | 22 | ## HTTP assertion table 23 | 24 | | part | predicates | types | 25 | |:------------|:---------------------------------------------|:--------------------------------------------------------| 26 | |headers |is, is_not, contains, does_not_contain |Vec<(HeaderName, HeaderValue)>, Vec<(&str, &str)>, HeaderMap | 27 | |header |is, is_not | String, &str, HeaderValue | 28 | |status |is, is_not, is_between |u16, StatusCode | 29 | |json_body |is, is_not, schema |String, &str, Value, `json!`, PathBuf | 30 | |json_path |is, is_not, schema, contains, does_not_contain, matches, does_not_match|String, &str, Value, `json!`, PathBuf | 31 | |response_time|is_less_than |u64 | 32 | 33 | ### Note about `json_path` 34 | 35 | Json path requires one more argument than other predicates because you have to provide a path. The 36 | expected value should always be a valid json representation. To enforce this the provided value is always converted to 37 | a `Value`. 38 | 39 | Here is an example of a json path assertion, where we are testing the value under the path `$[0].id`. 40 | 41 | ```rust 42 | #[tokio::test] 43 | async fn test_json_path() -> Result<()> { 44 | Grillon::new("https://jsonplaceholder.typicode.com")? 45 | .get("posts?id=1") 46 | .assert() 47 | .await 48 | .json_path("$[0].id", is(json!(1))) 49 | .json_path("$[0].id", is("1")); 50 | 51 | Ok(()) 52 | } 53 | ``` 54 | 55 | ## Custom assertions 56 | 57 | You may need to create more complex assertions or have more control on what is executed as part 58 | of an assertion. If so, the library provides a specific function, `assert_fn`, allowing you to write 59 | your own logic. 60 | 61 | ```rust 62 | Grillon::new("https://jsonplaceholder.typicode.com")? 63 | .post("posts") 64 | .payload(json!({ 65 | "title": "foo", 66 | "body": "bar", 67 | "userId": 1 68 | })) 69 | .assert() 70 | .await 71 | .status(is_success()) 72 | .assert_fn(|assert| { 73 | assert!(!assert.headers.is_empty()); 74 | assert!(assert.status == StatusCode::CREATED); 75 | assert!(assert.json.is_some()); 76 | 77 | println!("Json response : {:#?}", assert.json); 78 | }); 79 | ``` 80 | 81 | With this function you can access the `Assert` structure which is the internal representation of an 82 | http response under test. You should have access to all parts that Grillon supports (headers, status, json, etc.). It's also 83 | possible to add your own stdout logs if you want more control over the results or need to debug 84 | what you receive. 85 | -------------------------------------------------------------------------------- /book/src/writing_tests/client_configuration.md: -------------------------------------------------------------------------------- 1 | # Client configuration 2 | 3 | Grillon can be configured in different ways. We use [Hyper](https://github.com/hyperium/hyper) as 4 | the default HTTP client and provide you with a default configuration. By using Hyper, we can 5 | leverage on the low-level API to inspect HTTP requests and responses and provide interesting 6 | features to Grillon. 7 | 8 | ## Default client implementation 9 | 10 | The default client implementation should provide you with the most common features. All you need to 11 | do is configure the base API URL when you create an instance of Grillon. 12 | 13 | ```rust 14 | let grillon = Grillon::new("https://jsonplaceholder.typicode.com")?; 15 | ``` 16 | 17 | This way you don't have to rewrite the base URL each time you want to send a request and perform 18 | assertions on the response. You can reuse the existing client and create a new request. In the 19 | following example we send a `POST` request to `https://jsonplaceholder.typicode.com/posts`: 20 | 21 | ```rust 22 | let request = grillon 23 | .post("posts") 24 | .payload(json!({ 25 | "title": "foo", 26 | "body": "bar", 27 | "userId": 1 28 | })) 29 | .assert() 30 | .await; 31 | ``` 32 | 33 | The `assert` function consumes the 34 | [`grillon::Request`](https://docs.rs/grillon/latest/grillon/struct.Request.html) and prevents 35 | further changes to the structure of the request when users want to run assertions on the response. 36 | 37 | Refer to the [Requests](./requests.md) chapter for more information about how to configure your 38 | requests. Note that at the moment Grillon only supports HTTP(s), but later we will extend the use 39 | for different protocols and frameworks such as gRPC or SSL. 40 | 41 | ### Session and authentication 42 | 43 | #### Store cookies 44 | 45 | You can update your client to enable or disable the cookie store with `store_cookies`: 46 | 47 | ```rust 48 | // If an http response contains `Set-Cookie` headers, then cookies will be saved for 49 | // subsequent http requests. 50 | let grillon = Grillon::new("https://server.com/")?.store_cookies(true)?; 51 | 52 | grillon.post("auth").assert().await.headers(contains(vec![( 53 | SET_COOKIE, 54 | HeaderValue::from_static("SESSIONID=123; HttpOnly"), 55 | )])); 56 | 57 | grillon 58 | .get("authenticated/endpoint") // An endpoint where the session cookie `SESSIONID=123` is required. 59 | .assert() 60 | .await 61 | .status(is_success()); 62 | ``` 63 | 64 | The cookie store is disabled by default. 65 | 66 | #### Basic Auth 67 | 68 | You can easily configure your headers with the `basic_auth` function to set a per-request authentication: 69 | 70 | ```rust 71 | Grillon::new("https://server.com/")? 72 | .get("auth/basic/endpoint") 73 | .basic_auth("isaac", Some("rayne")) 74 | .assert() 75 | .await 76 | .status(is_success()); 77 | ``` 78 | 79 | Note that the header is considered as sensitive and will not be logged. 80 | 81 | #### Bearer token 82 | 83 | You can also use the `bearer_auth` function to set your `Bearer` header per-request: 84 | 85 | ```rust 86 | Grillon::new("https://server.com/")? 87 | .get("auth/bearer/endpoint") 88 | .bearer_auth("token-123") 89 | .assert() 90 | .await 91 | .status(is_success()); 92 | ``` 93 | 94 | This header is also considered as sensitive and will not be logged. 95 | 96 | ## Use a different client 97 | 98 | When you want to use a different client to send your requests and handle the responses, you should 99 | use the internal http response representation to assert. For that you need to use the 100 | [`Assert`](https://docs.rs/grillon/latest/grillon/struct.Assert.html) structure. 101 | 102 | For example, suppose you want to use `reqwest` to perform an http `GET` request and you want to 103 | assert that the `response time` is `less than` 400ms. First you need to create your own structure 104 | that will handle a `reqwest::Response`. 105 | 106 | ```rust 107 | struct MyResponse { 108 | pub response: reqwest::Response, 109 | } 110 | ``` 111 | 112 | Next, you need to implement the 113 | [`grillon::Response`](https://docs.rs/grillon/latest/grillon/trait.Response.html) trait to describe 114 | how you handle the various pieces of information that Grillon needs to perform assertions: 115 | 116 | ```rust 117 | #[async_trait(?Send)] 118 | impl Response for MyResponse { 119 | fn status(&self) -> StatusCode { 120 | self.response.status() 121 | } 122 | 123 | async fn json(self) -> Option { 124 | self.response.json::().await.ok() 125 | } 126 | 127 | fn headers(&self) -> HeaderMap { 128 | self.response.headers().clone() 129 | } 130 | } 131 | ``` 132 | 133 | The next step is to create a new `Assert` instance which requires: 134 | 135 | - An implementation of a `grillon::Response`, 136 | - the response time in milliseconds, 137 | - and the [`LogSettings`](https://docs.rs/grillon/latest/grillon/enum.LogSettings.html) for the 138 | assertion results. 139 | 140 | Let's first run the request and get the execution time: 141 | 142 | ```rust 143 | let now = Instant::now(); 144 | let response = reqwest::get(mock_server.server.url("/users/1")) 145 | .await 146 | .expect("Failed to send the http request"); 147 | let response_time = now.elapsed().as_millis() as u64; 148 | ``` 149 | 150 | Now let's pass the response to your own response structure: 151 | 152 | ```rust 153 | let my_response = MyResponse { response }; 154 | ``` 155 | 156 | You are now ready to assert against a `reqwest::Response` wrapped by your own implementation of a 157 | `grillon::Response`: 158 | 159 | ```rust 160 | Assert::new(my_response, response_time, LogSettings::default()) 161 | .await 162 | .response_time(is_less_than(400)); 163 | ``` 164 | -------------------------------------------------------------------------------- /book/src/writing_tests/index.md: -------------------------------------------------------------------------------- 1 | # Writing tests 2 | 3 | - [Client configuration](./client_configuration.md) 4 | - [Requests](./requests.md) 5 | - [Assertions](./assertions.md) 6 | -------------------------------------------------------------------------------- /book/src/writing_tests/requests.md: -------------------------------------------------------------------------------- 1 | # Requests 2 | 3 | ## HTTP 4 | 5 | With Grillon, you can easily chain calls to configure and send HTTP requests and reuse the same 6 | client for a given base api URL. 7 | 8 | ```rust 9 | #[tokio::test] 10 | async fn test_get_jsonplaceholder() -> Result<()> { 11 | let grillon = Grillon::new("https://jsonplaceholder.typicode.com")?; 12 | 13 | grillon 14 | .get("posts?id=1") 15 | .assert() 16 | .await 17 | .json_path("$[0].id", is(json!(1))); 18 | 19 | grillon 20 | .get("posts?id=2") 21 | .assert() 22 | .await 23 | .json_path("$[0].id", is(json!(2))); 24 | 25 | Ok(()) 26 | } 27 | ``` 28 | 29 | ### Methods 30 | 31 | Each method in this list has its corresponding `lowercase` function: 32 | 33 | - GET 34 | - POST 35 | - PUT 36 | - PATCH 37 | - DELETE 38 | - OPTIONS 39 | - CONNECT 40 | - HEAD 41 | 42 | ### Headers 43 | 44 | Grillon supports two different types to configuring http request headers: 45 | 46 | - `HeaderMap` 47 | - `Vec<(HeaderName, HeaderValue)>` 48 | 49 | ```rust 50 | let grillon = Grillon::new("https://jsonplaceholder.typicode.com")?; 51 | 52 | // Vec<(HeaderName, HeaderValue)> 53 | let request = grillon 54 | .post("posts") 55 | .payload(json!({ 56 | "title": "foo", 57 | "body": "bar", 58 | "userId": 1 59 | })) 60 | .headers(vec![( 61 | CONTENT_TYPE, 62 | HeaderValue::from_static("application/json"), 63 | )]); 64 | 65 | // Override with HeaderMap 66 | let mut header_map = HeaderMap::new(); 67 | header_map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); 68 | let request = request.headers(header_map); 69 | ``` 70 | 71 | ### Payload 72 | 73 | At the moment, Grillon only supports the `application/json` content type. It will then be extended 74 | with different content types such as `multipart/form-data`, `application/x-www-form-urlencoded`, 75 | `text/plain` or `text/html`. 76 | 77 | #### Json 78 | 79 | Grillon re-exports `serde_json::Value` type to make it easier to add a json body. You can also use 80 | the `json!` macro. 81 | 82 | ```rust 83 | Grillon::new("https://jsonplaceholder.typicode.com")?; 84 | .post("posts") 85 | .payload(json!({ 86 | "title": "foo", 87 | "body": "bar", 88 | "userId": 1 89 | })) 90 | .assert() 91 | .await; 92 | ``` 93 | 94 | ### Build a custom request 95 | 96 | If for some reasons you need a more programmatic way to create your http requests, you can use the 97 | `http_request` function: 98 | 99 | ```rust 100 | Grillon::new("https://jsonplaceholder.typicode.com")? 101 | .http_request(Method::POST, "posts") 102 | .assert() 103 | .await 104 | .status(is_success()); 105 | ``` 106 | -------------------------------------------------------------------------------- /src/assert.rs: -------------------------------------------------------------------------------- 1 | //! The `assert` module provides everything to assert parts of http responses with built-in matchers. 2 | //! 3 | //! [`Assert`] can be used separately with your own [`Response`] implementation which makes it 4 | //! handy if you want to use your own http client to send requests and handle responses. 5 | //! 6 | //! # Example of usage with `reqwest` 7 | //! 8 | //! ```rust 9 | //! #[tokio::test] 10 | //! async fn custom_response_struct() -> Result<(), grillon::Error> { 11 | //! use async_trait::async_trait; 12 | //! use grillon::{header::HeaderMap, Assert, Response, StatusCode}; 13 | //! use serde_json::Value; 14 | //! 15 | //! struct ResponseWrapper { 16 | //! pub response: reqwest::Response, 17 | //! } 18 | //! 19 | //! #[async_trait(?Send)] 20 | //! impl Response for ResponseWrapper { 21 | //! fn status(&self) -> StatusCode { 22 | //! self.response.status() 23 | //! } 24 | //! 25 | //! async fn json(self) -> Option { 26 | //! self.response.json::().await.ok() 27 | //! } 28 | //! 29 | //! fn headers(&self) -> HeaderMap { 30 | //! self.response.headers().clone() 31 | //! } 32 | //! } 33 | //! 34 | //! let response = reqwest::get("https://jsonplaceholder.typicode.com/users/1") 35 | //! .await 36 | //! .expect("Valid reqwest::Response"); 37 | //! let response_wrapper = ResponseWrapper { response }; 38 | //! 39 | //! Assert::new(response_wrapper).await.status(is_between(200, 299)); 40 | //! 41 | //! Ok(()) 42 | //! } 43 | //! ``` 44 | 45 | use crate::assertion::{Assertion, AssertionResult, Hand, UnprocessableReason}; 46 | use crate::dsl::http::*; 47 | use crate::dsl::json_path::{JsonPathDsl, JsonPathResult}; 48 | use crate::dsl::{Expression, Part}; 49 | use crate::grillon::LogSettings; 50 | use crate::Response; 51 | use http::HeaderValue; 52 | use http::{header::AsHeaderName, HeaderMap, StatusCode}; 53 | use serde_json::Value; 54 | 55 | /// [`Assert`] uses an internal representation of the http response to assert 56 | /// against. If the HTTP request was successfully sent, then each field will be 57 | /// `Some`, otherwise `None`. 58 | #[derive(Clone)] 59 | pub struct Assert { 60 | /// The http response header to assert. 61 | pub headers: Option, 62 | /// The http response status to assert. 63 | pub status: Option, 64 | /// The http response json body to assert. 65 | pub json: Option>, 66 | /// The http response time (in milliseconds) to assert. 67 | pub response_time_ms: Option, 68 | /// The test results output. 69 | pub log_settings: LogSettings, 70 | } 71 | 72 | impl Assert { 73 | /// Creates an `Assert` instance with an internal representation 74 | /// of the given response to assert. 75 | pub async fn new( 76 | response: Option, 77 | response_time_ms: Option, 78 | log_settings: LogSettings, 79 | ) -> Self { 80 | if let Some(response) = response { 81 | return Assert { 82 | headers: Some(response.headers().clone()), 83 | status: Some(response.status()), 84 | json: Some(response.json().await), 85 | response_time_ms, 86 | log_settings, 87 | }; 88 | }; 89 | 90 | Assert { 91 | headers: None, 92 | status: None, 93 | json: None, 94 | response_time_ms: None, 95 | log_settings, 96 | } 97 | } 98 | 99 | /// Extends the built-in assertions with a custom assertion. 100 | /// The closure gives access to the [`Assert`] instance. 101 | /// 102 | /// # Example 103 | /// 104 | /// ```rust 105 | /// # use grillon::{Grillon, Result, StatusCode, dsl::{is, is_between}}; 106 | /// # async fn custom_assert() -> Result<()> { 107 | /// Grillon::new("https://jsonplaceholder.typicode.com")? 108 | /// .get("/users") 109 | /// .assert() 110 | /// .await 111 | /// .status(is_between(200, 299)) 112 | /// .assert_fn(|assert| { 113 | /// assert!(!assert.headers.is_empty()); 114 | /// assert!(assert.status == StatusCode::CREATED); 115 | /// assert!(assert.json.is_some()); 116 | /// 117 | /// println!("Json response : {:#?}", assert.json); 118 | /// }) 119 | /// .status(is(StatusCode::CREATED)); 120 | /// 121 | /// # Ok(()) 122 | /// # } 123 | /// ``` 124 | pub fn assert_fn(self, func: F) -> Assert 125 | where 126 | F: for<'a> Fn(&'a Assert), 127 | { 128 | func(&self); 129 | 130 | self 131 | } 132 | 133 | /// Asserts the status of the response. 134 | pub fn status(self, expr: Expression) -> Assert 135 | where 136 | T: StatusCodeDsl, 137 | { 138 | if let Some(status) = self.status { 139 | let _assertion = expr.value.eval(status, expr.predicate, &self.log_settings); 140 | } 141 | 142 | self 143 | } 144 | 145 | /// Asserts the json body of the response. 146 | pub fn json_body(self, expr: Expression) -> Assert 147 | where 148 | T: JsonBodyDsl, 149 | { 150 | if let Some(json) = &self.json { 151 | let actual = if let Some(body) = json.clone() { 152 | body 153 | } else { 154 | let assertion = Assertion { 155 | part: Part::JsonPath, 156 | predicate: expr.predicate, 157 | left: Hand::Empty::, 158 | right: Hand::Empty, 159 | result: AssertionResult::Unprocessable(UnprocessableReason::MissingJsonBody), 160 | }; 161 | assertion.assert(&self.log_settings); 162 | 163 | return self; 164 | }; 165 | let _assertion = expr.value.eval(actual, expr.predicate, &self.log_settings); 166 | } 167 | 168 | self 169 | } 170 | 171 | /// Asserts the value found at the given json path. 172 | pub fn json_path(self, path: &str, expr: Expression) -> Assert 173 | where 174 | T: JsonPathDsl, 175 | { 176 | use jsonpath_rust::JsonPathQuery; 177 | 178 | if let Some(json) = &self.json { 179 | // Check for empty json body 180 | let json_body = if let Some(body) = json.clone() { 181 | body 182 | } else { 183 | let assertion = Assertion { 184 | part: Part::JsonPath, 185 | predicate: expr.predicate, 186 | left: Hand::Empty::, 187 | right: Hand::Empty, 188 | result: AssertionResult::Unprocessable(UnprocessableReason::MissingJsonBody), 189 | }; 190 | assertion.assert(&self.log_settings); 191 | 192 | return self; 193 | }; 194 | 195 | // Check for unprocessable json path 196 | let jsonpath_value = match json_body.path(path) { 197 | Ok(json) => json, 198 | Err(_) => { 199 | let assertion = Assertion { 200 | part: Part::JsonPath, 201 | predicate: expr.predicate, 202 | left: Hand::Empty::, 203 | right: Hand::Empty, 204 | result: AssertionResult::Unprocessable( 205 | UnprocessableReason::InvalidJsonPath(path.to_string()), 206 | ), 207 | }; 208 | assertion.assert(&self.log_settings); 209 | 210 | return self; 211 | } 212 | }; 213 | 214 | let jsonpath_res = JsonPathResult::new(path, jsonpath_value); 215 | 216 | let _assertion = expr 217 | .value 218 | .eval(jsonpath_res, expr.predicate, &self.log_settings); 219 | } 220 | 221 | self 222 | } 223 | 224 | /// Asserts the response time (in milliseconds). 225 | pub fn response_time(self, expr: Expression) -> Assert 226 | where 227 | T: TimeDsl, 228 | { 229 | if let Some(response_time_ms) = self.response_time_ms { 230 | let _assertion = expr 231 | .value 232 | .eval(response_time_ms, expr.predicate, &self.log_settings); 233 | } 234 | 235 | self 236 | } 237 | 238 | /// Asserts the headers of the response. 239 | pub fn headers(self, expr: Expression) -> Assert 240 | where 241 | T: HeadersDsl, 242 | { 243 | if let Some(headers) = &self.headers { 244 | let _assertion = expr 245 | .value 246 | .eval(headers.clone(), expr.predicate, &self.log_settings); 247 | } 248 | 249 | self 250 | } 251 | 252 | /// Asserts a specific header of the response. 253 | pub fn header(self, header_name: H, expr: Expression) -> Assert 254 | where 255 | H: AsHeaderName, 256 | T: HeaderDsl, 257 | { 258 | if let Some(headers) = self.headers.clone() { 259 | if let Some(actual_header_val) = headers.get(header_name) { 260 | let _assertion = expr.value.eval( 261 | actual_header_val.clone(), 262 | expr.predicate, 263 | &self.log_settings, 264 | ); 265 | } else { 266 | // Handle missing header name 267 | let assertion = Assertion { 268 | part: Part::Header, 269 | predicate: expr.predicate, 270 | left: Hand::Empty::<&str>, 271 | right: Hand::Empty, 272 | result: AssertionResult::Unprocessable(UnprocessableReason::MissingHeader), 273 | }; 274 | assertion.assert(&self.log_settings); 275 | } 276 | } 277 | 278 | self 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /src/assertion/impls/json_body.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | assertion::{ 3 | traits::{Equality, JsonSchema}, 4 | Assertion, AssertionResult, Hand, UnprocessableReason, 5 | }, 6 | dsl::{Part, Predicate}, 7 | }; 8 | use jsonschema::{output::BasicOutput, validator_for}; 9 | use serde_json::Value; 10 | use std::{fs, path::PathBuf}; 11 | 12 | impl Equality for Value { 13 | type Assertion = Assertion; 14 | 15 | fn is_eq(&self, rhs: &Value) -> Self::Assertion { 16 | let result = self == rhs; 17 | 18 | Assertion { 19 | predicate: Predicate::Is, 20 | part: Part::JsonBody, 21 | left: Hand::Left(self.clone()), 22 | right: Hand::Right(rhs.clone()), 23 | result: result.into(), 24 | } 25 | } 26 | 27 | fn is_ne(&self, rhs: &Value) -> Self::Assertion { 28 | let result = self != rhs; 29 | 30 | Assertion { 31 | predicate: Predicate::IsNot, 32 | part: Part::JsonBody, 33 | left: Hand::Left(self.clone()), 34 | right: Hand::Right(rhs.clone()), 35 | result: result.into(), 36 | } 37 | } 38 | } 39 | 40 | impl Equality for Value { 41 | type Assertion = Assertion; 42 | 43 | fn is_eq(&self, rhs: &str) -> Self::Assertion { 44 | let rhs: Value = match serde_json::from_str(rhs) { 45 | Ok(value) => value, 46 | Err(err) => { 47 | return Assertion { 48 | predicate: Predicate::Is, 49 | part: Part::JsonBody, 50 | left: Hand::Empty, 51 | right: Hand::Empty, 52 | result: AssertionResult::Unprocessable( 53 | UnprocessableReason::SerializationFailure(err.to_string()), 54 | ), 55 | } 56 | } 57 | }; 58 | self.is_eq(&rhs) 59 | } 60 | 61 | fn is_ne(&self, rhs: &str) -> Self::Assertion { 62 | let rhs: Value = match serde_json::from_str(rhs) { 63 | Ok(value) => value, 64 | Err(err) => { 65 | return Assertion { 66 | predicate: Predicate::IsNot, 67 | part: Part::JsonBody, 68 | left: Hand::Empty, 69 | right: Hand::Empty, 70 | result: AssertionResult::Unprocessable( 71 | UnprocessableReason::SerializationFailure(err.to_string()), 72 | ), 73 | } 74 | } 75 | }; 76 | self.is_ne(&rhs) 77 | } 78 | } 79 | 80 | impl Equality for Value { 81 | type Assertion = Assertion; 82 | 83 | fn is_eq(&self, rhs: &String) -> Self::Assertion { 84 | let rhs: Value = match serde_json::from_str(rhs) { 85 | Ok(value) => value, 86 | Err(err) => { 87 | return Assertion { 88 | predicate: Predicate::Is, 89 | part: Part::JsonBody, 90 | left: Hand::Empty, 91 | right: Hand::Empty, 92 | result: AssertionResult::Unprocessable( 93 | UnprocessableReason::SerializationFailure(err.to_string()), 94 | ), 95 | } 96 | } 97 | }; 98 | self.is_eq(&rhs) 99 | } 100 | 101 | fn is_ne(&self, rhs: &String) -> Self::Assertion { 102 | let rhs: Value = match serde_json::from_str(rhs) { 103 | Ok(value) => value, 104 | Err(err) => { 105 | return Assertion { 106 | predicate: Predicate::IsNot, 107 | part: Part::JsonBody, 108 | left: Hand::Empty, 109 | right: Hand::Empty, 110 | result: AssertionResult::Unprocessable( 111 | UnprocessableReason::SerializationFailure(err.to_string()), 112 | ), 113 | } 114 | } 115 | }; 116 | self.is_ne(&rhs) 117 | } 118 | } 119 | 120 | impl Equality for Value { 121 | type Assertion = Assertion; 122 | 123 | fn is_eq(&self, json_file: &PathBuf) -> Self::Assertion { 124 | let json_file = match fs::read_to_string(json_file) { 125 | Ok(content) => content, 126 | Err(_) => { 127 | return Assertion { 128 | predicate: Predicate::Is, 129 | part: Part::JsonBody, 130 | left: Hand::Empty, 131 | right: Hand::Empty, 132 | result: AssertionResult::Unprocessable(UnprocessableReason::Other(format!( 133 | "Failed to read json file located at {}", 134 | json_file.display() 135 | ))), 136 | } 137 | } 138 | }; 139 | 140 | let expected_json: Value = match serde_json::from_str(&json_file) { 141 | Ok(json) => json, 142 | Err(_) => { 143 | return Assertion { 144 | predicate: Predicate::Is, 145 | part: Part::JsonBody, 146 | left: Hand::Empty, 147 | right: Hand::Empty, 148 | result: AssertionResult::Unprocessable( 149 | UnprocessableReason::SerializationFailure( 150 | "Failed to serialize file content".to_string(), 151 | ), 152 | ), 153 | } 154 | } 155 | }; 156 | 157 | self.is_eq(&expected_json) 158 | } 159 | 160 | fn is_ne(&self, json_file: &PathBuf) -> Self::Assertion { 161 | let json_file = match fs::read_to_string(json_file) { 162 | Ok(content) => content, 163 | Err(_) => { 164 | return Assertion { 165 | predicate: Predicate::IsNot, 166 | part: Part::JsonBody, 167 | left: Hand::Empty, 168 | right: Hand::Empty, 169 | result: AssertionResult::Unprocessable(UnprocessableReason::Other(format!( 170 | "Failed to read json file located at {}", 171 | json_file.display() 172 | ))), 173 | } 174 | } 175 | }; 176 | 177 | let expected_json: Value = match serde_json::from_str(&json_file) { 178 | Ok(json) => json, 179 | Err(_) => { 180 | return Assertion { 181 | predicate: Predicate::IsNot, 182 | part: Part::JsonBody, 183 | left: Hand::Empty, 184 | right: Hand::Empty, 185 | result: AssertionResult::Unprocessable( 186 | UnprocessableReason::SerializationFailure( 187 | "Failed to serialize file content".to_string(), 188 | ), 189 | ), 190 | } 191 | } 192 | }; 193 | 194 | self.is_ne(&expected_json) 195 | } 196 | } 197 | 198 | impl JsonSchema for Value { 199 | type Assertion = Assertion; 200 | 201 | fn matches_schema(&self, schema: &Value) -> Self::Assertion { 202 | let schema = match validator_for(schema) { 203 | Ok(schema) => schema, 204 | Err(err) => { 205 | return Assertion { 206 | predicate: Predicate::Schema, 207 | part: Part::JsonBody, 208 | left: Hand::Left(self.clone()), 209 | right: Hand::Empty, 210 | result: AssertionResult::Unprocessable(UnprocessableReason::InvalidJsonSchema( 211 | err.instance_path.to_string(), 212 | err.instance.to_string(), 213 | )), 214 | } 215 | } 216 | }; 217 | 218 | // Get the boolean result of the validation 219 | let result = schema.is_valid(self); 220 | 221 | // Generate a json output of the json schema result 222 | let output: BasicOutput<'_> = schema.apply(self).basic(); 223 | let output = match serde_json::to_value(output) { 224 | Ok(json_output) => json_output, 225 | Err(_) => { 226 | return Assertion { 227 | predicate: Predicate::Schema, 228 | part: Part::JsonBody, 229 | left: Hand::Empty, 230 | right: Hand::Empty, 231 | result: AssertionResult::Unprocessable(UnprocessableReason::Other( 232 | "Failed to serialize json schema error".to_string(), 233 | )), 234 | } 235 | } 236 | }; 237 | 238 | Assertion { 239 | predicate: Predicate::Schema, 240 | part: Part::JsonBody, 241 | left: Hand::Left(self.clone()), 242 | right: Hand::Right(output), 243 | result: result.into(), 244 | } 245 | } 246 | } 247 | 248 | impl JsonSchema for Value { 249 | type Assertion = Assertion; 250 | 251 | fn matches_schema(&self, schema: &str) -> Self::Assertion { 252 | let schema: Value = match serde_json::from_str(schema) { 253 | Ok(schema) => schema, 254 | Err(err) => { 255 | return Assertion { 256 | predicate: Predicate::Schema, 257 | part: Part::JsonBody, 258 | left: Hand::Empty, 259 | right: Hand::Empty, 260 | result: AssertionResult::Unprocessable( 261 | UnprocessableReason::SerializationFailure(err.to_string()), 262 | ), 263 | } 264 | } 265 | }; 266 | 267 | self.matches_schema(&schema) 268 | } 269 | } 270 | 271 | impl JsonSchema for Value { 272 | type Assertion = Assertion; 273 | 274 | fn matches_schema(&self, schema: &String) -> Self::Assertion { 275 | let schema: Value = match serde_json::from_str(schema) { 276 | Ok(schema) => schema, 277 | Err(_) => { 278 | return Assertion { 279 | predicate: Predicate::Schema, 280 | part: Part::JsonBody, 281 | left: Hand::Empty, 282 | right: Hand::Empty, 283 | result: AssertionResult::Unprocessable(UnprocessableReason::Other( 284 | "Failed to serialize json schema".to_string(), 285 | )), 286 | } 287 | } 288 | }; 289 | 290 | self.matches_schema(&schema) 291 | } 292 | } 293 | 294 | impl JsonSchema for Value { 295 | type Assertion = Assertion; 296 | 297 | fn matches_schema(&self, schema_file: &PathBuf) -> Self::Assertion { 298 | let schema_file_content = match fs::read_to_string(schema_file) { 299 | Ok(content) => content, 300 | Err(_) => { 301 | return Assertion { 302 | predicate: Predicate::Schema, 303 | part: Part::JsonBody, 304 | left: Hand::Empty, 305 | right: Hand::Empty, 306 | result: AssertionResult::Unprocessable(UnprocessableReason::Other(format!( 307 | "Failed to read json schema file located at {}", 308 | schema_file.display() 309 | ))), 310 | } 311 | } 312 | }; 313 | 314 | let schema: Value = match serde_json::from_str(&schema_file_content) { 315 | Ok(schema) => schema, 316 | Err(_) => { 317 | return Assertion { 318 | predicate: Predicate::Schema, 319 | part: Part::JsonBody, 320 | left: Hand::Empty, 321 | right: Hand::Empty, 322 | result: AssertionResult::Unprocessable(UnprocessableReason::Other( 323 | "Failed to serialize json schema".to_string(), 324 | )), 325 | } 326 | } 327 | }; 328 | 329 | self.matches_schema(&schema) 330 | } 331 | } 332 | 333 | #[cfg(test)] 334 | mod tests { 335 | use crate::assertion::traits::Equality; 336 | use serde_json::{json, Value}; 337 | use std::path::PathBuf; 338 | 339 | fn value_stub() -> Value { 340 | json!({ 341 | "a_string": "john", 342 | "a_number": 12, 343 | "a_string_number": "12", 344 | "a_vec": [ 345 | { 346 | "entry1": "entry1" 347 | }, 348 | { 349 | "entry2": "entry2" 350 | } 351 | ] 352 | }) 353 | } 354 | 355 | #[test] 356 | fn impl_is_eq_value() { 357 | let assertion = value_stub().is_eq(&value_stub()); 358 | assert!(assertion.passed(), "{}", assertion.log()); 359 | } 360 | 361 | #[test] 362 | fn impl_is_eq_with_different_order() { 363 | let json_value = json!({ 364 | "a_string_number": "12", 365 | "a_number": 12, 366 | "a_vec": [ 367 | { 368 | "entry1": "entry1" 369 | }, 370 | { 371 | "entry2": "entry2" 372 | } 373 | ], 374 | "a_string": "john", 375 | }); 376 | let json_string = json_value.to_string(); 377 | let json_str = json_string.as_str(); 378 | 379 | let value_assertion = value_stub().is_eq(&json_value); 380 | let str_assertion = value_stub().is_eq(json_str); 381 | let string_assertion = value_stub().is_eq(&json_string); 382 | 383 | assert!(value_assertion.passed(), "{}", value_assertion.log()); 384 | assert!(str_assertion.passed(), "{}", str_assertion.log()); 385 | assert!(string_assertion.passed(), "{}", string_assertion.log()); 386 | } 387 | 388 | #[test] 389 | fn impl_is_eq_str() { 390 | let json_str = r#"{ 391 | "a_string": "john", 392 | "a_number": 12, 393 | "a_string_number": "12", 394 | "a_vec": [ 395 | { 396 | "entry1": "entry1" 397 | }, 398 | { 399 | "entry2": "entry2" 400 | } 401 | ] 402 | }"#; 403 | 404 | let assertion = value_stub().is_eq(json_str); 405 | assert!(assertion.passed(), "{}", assertion.log()); 406 | } 407 | 408 | #[test] 409 | fn impl_is_eq_string() { 410 | let json_string = r#"{ 411 | "a_string": "john", 412 | "a_number": 12, 413 | "a_string_number": "12", 414 | "a_vec": [ 415 | { 416 | "entry1": "entry1" 417 | }, 418 | { 419 | "entry2": "entry2" 420 | } 421 | ] 422 | }"# 423 | .to_string(); 424 | 425 | let assertion = value_stub().is_eq(&json_string); 426 | assert!(assertion.passed(), "{}", assertion.log()); 427 | } 428 | 429 | #[test] 430 | fn impl_is_eq_json_file() { 431 | let value = value_stub(); 432 | let json_file = 433 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/json_body.json"); 434 | 435 | let assertion = value.is_eq(&json_file); 436 | assert!(assertion.passed(), "{}", assertion.log()); 437 | } 438 | 439 | #[test] 440 | fn impl_is_eq_inexistant_json_file() { 441 | let value = value_stub(); 442 | let json_file = 443 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/inexistant.json"); 444 | 445 | let assertion = value.is_eq(&json_file); 446 | assert!(assertion.failed(), "{}", assertion.log()); 447 | let expected_error_msg_part = "Failed to read json file located at"; 448 | 449 | assert!( 450 | assertion.log().contains(expected_error_msg_part), 451 | "The error message doesn't contain this: {expected_error_msg_part}" 452 | ); 453 | } 454 | 455 | #[test] 456 | fn impl_is_ne_value() { 457 | let other_value = json!({ 458 | "another_string": "john" 459 | }); 460 | let assertion = value_stub().is_ne(&other_value); 461 | assert!(assertion.passed(), "{}", assertion.log()); 462 | } 463 | 464 | #[test] 465 | fn impl_is_ne_str() { 466 | let json_str = r#"{ 467 | "a_string": "john", 468 | "a_number": 12 469 | }"#; 470 | 471 | let assertion = value_stub().is_ne(json_str); 472 | assert!(assertion.passed(), "{}", assertion.log()); 473 | } 474 | 475 | #[test] 476 | fn impl_is_ne_string() { 477 | let json_string = r#"{ 478 | "a_string": "john", 479 | "a_number": 12 480 | }"# 481 | .to_string(); 482 | 483 | let assertion = value_stub().is_ne(&json_string); 484 | assert!(assertion.passed(), "{}", assertion.log()); 485 | } 486 | 487 | #[test] 488 | fn impl_is_ne_different_type() { 489 | let assertion = json!({"age": "12"}).is_ne(r#"{"age": 12}"#); 490 | assert!(assertion.passed(), "{}", assertion.log()); 491 | } 492 | 493 | #[test] 494 | fn impl_is_ne_json_file() { 495 | let value = json!({"not_the_same": "content"}); 496 | let json_file = 497 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/json_body.json"); 498 | 499 | let assertion = value.is_ne(&json_file); 500 | assert!(assertion.passed(), "{}", assertion.log()); 501 | } 502 | 503 | mod serialization { 504 | use super::*; 505 | use serde_json::json; 506 | 507 | #[test] 508 | fn it_serializes_json_body_should_be() { 509 | let response_payload = json!({ 510 | "user": "john", 511 | "age": 23 512 | }); 513 | 514 | let expected_json = json!({ 515 | "part": "json body", 516 | "predicate": "should be", 517 | "left": response_payload, 518 | "right": response_payload, 519 | "result": "passed" 520 | }); 521 | 522 | let assertion = response_payload.is_eq(&response_payload); 523 | 524 | assert_eq!( 525 | json!(assertion), 526 | expected_json, 527 | "Serialized assertion is not equals to the expected json", 528 | ); 529 | } 530 | 531 | #[test] 532 | fn it_serializes_json_body_should_not_be() { 533 | let response_payload = json!({ 534 | "user": "john", 535 | "age": 23 536 | }); 537 | 538 | let expected_json = json!({ 539 | "part": "json body", 540 | "predicate": "should not be", 541 | "left": value_stub(), 542 | "right": response_payload, 543 | "result": "passed" 544 | }); 545 | 546 | let assertion = value_stub().is_ne(&response_payload); 547 | 548 | assert_eq!( 549 | json!(assertion), 550 | expected_json, 551 | "Serialized assertion is not equals to the expected json", 552 | ); 553 | } 554 | } 555 | 556 | mod schema { 557 | use crate::assertion::traits::JsonSchema; 558 | use serde_json::json; 559 | 560 | #[test] 561 | fn impl_json_schema_is_valid() { 562 | let schema = json!({ 563 | "$schema": "http://json-schema.org/draft-04/schema#", 564 | "title": "Age validation schema", 565 | "type": "object", 566 | "properties": { 567 | "age": { 568 | "description": "Age in years", 569 | "type": "string", 570 | "minimum": 0 571 | } 572 | }, 573 | "required": ["age"] 574 | }); 575 | 576 | let assertion = json!({"age": "12"}).matches_schema(&schema); 577 | assert!(assertion.passed(), "{}", assertion.log()); 578 | } 579 | 580 | #[test] 581 | fn impl_str_schema_is_valid() { 582 | let schema = r#"{ 583 | "$schema": "http://json-schema.org/draft-04/schema#", 584 | "title": "Age validation schema", 585 | "type": "object", 586 | "properties": { 587 | "age": { 588 | "description": "Age in years", 589 | "type": "string", 590 | "minimum": 0 591 | } 592 | }, 593 | "required": ["age"] 594 | }"#; 595 | 596 | let assertion = json!({"age": "12"}).matches_schema(schema); 597 | assert!(assertion.passed(), "{}", assertion.log()); 598 | } 599 | 600 | #[test] 601 | fn impl_string_schema_is_valid() { 602 | let schema = r#"{ 603 | "$schema": "http://json-schema.org/draft-04/schema#", 604 | "title": "Age validation schema", 605 | "type": "object", 606 | "properties": { 607 | "age": { 608 | "description": "Age in years", 609 | "type": "string", 610 | "minimum": 0 611 | } 612 | }, 613 | "required": ["age"] 614 | }"# 615 | .to_string(); 616 | 617 | let assertion = json!({"age": "12"}).matches_schema(&schema); 618 | assert!(assertion.passed(), "{}", assertion.log()); 619 | } 620 | 621 | #[test] 622 | fn impl_schema_is_invalid() { 623 | let schema: serde_json::Value = json!({ 624 | "$schema": "http://json-schema.org/draft-04/schema#", 625 | "title": "Age validation schema", 626 | "type": "object", 627 | "properties": { 628 | "age": { 629 | "description": "Age in years", 630 | "type": "number", 631 | "minimum": 0 632 | } 633 | }, 634 | "required": ["age"] 635 | }); 636 | 637 | // providing a string instead of a number should fail 638 | let assertion = json!({"age": "12"}).matches_schema(&schema); 639 | assert!(assertion.failed(), "{}", assertion.log()); 640 | } 641 | 642 | #[test] 643 | fn impl_schema_validation_error() { 644 | let schema: serde_json::Value = json!({ 645 | "$schema": "http://json-schema.org/draft-04/schema#", 646 | "title": "Bad json schema", 647 | "type": "object", 648 | "properties": { 649 | "age": { 650 | "description": "Age in years", 651 | "type": "string", 652 | "minimum": 0, 653 | // Invalid JSON Schema additional property that should be an array 654 | // https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.6.5.3 655 | "required": true 656 | } 657 | }, 658 | "required": ["age"] 659 | }); 660 | 661 | let assertion = json!({"age": "12"}).matches_schema(&schema); 662 | let log = assertion.log(); 663 | assert!(assertion.failed(), "{log}"); 664 | assert_eq!(log, "Invalid json schema: /properties/age/required => true"); 665 | } 666 | } 667 | } 668 | -------------------------------------------------------------------------------- /src/assertion/impls/mod.rs: -------------------------------------------------------------------------------- 1 | mod header; 2 | mod json_body; 3 | mod json_path; 4 | mod status; 5 | mod time; 6 | -------------------------------------------------------------------------------- /src/assertion/impls/status.rs: -------------------------------------------------------------------------------- 1 | use crate::assertion::traits::{Equality, RangeInclusive}; 2 | use crate::assertion::{Assertion, Hand}; 3 | use crate::dsl::{Part, Predicate}; 4 | use crate::StatusCode; 5 | 6 | impl Equality for StatusCode { 7 | type Assertion = Assertion; 8 | 9 | fn is_eq(&self, rhs: &u16) -> Self::Assertion { 10 | let lhs = self.as_u16(); 11 | 12 | Assertion { 13 | predicate: Predicate::Is, 14 | part: Part::StatusCode, 15 | left: Hand::Left(lhs), 16 | right: Hand::Right(*rhs), 17 | result: (self == rhs).into(), 18 | } 19 | } 20 | 21 | fn is_ne(&self, rhs: &u16) -> Self::Assertion { 22 | let lhs = self.as_u16(); 23 | 24 | Assertion { 25 | predicate: Predicate::IsNot, 26 | part: Part::StatusCode, 27 | left: Hand::Left(lhs), 28 | right: Hand::Right(*rhs), 29 | result: (self != rhs).into(), 30 | } 31 | } 32 | } 33 | 34 | impl Equality for StatusCode { 35 | type Assertion = Assertion; 36 | 37 | fn is_eq(&self, rhs: &StatusCode) -> Self::Assertion { 38 | Assertion { 39 | predicate: Predicate::Is, 40 | part: Part::StatusCode, 41 | left: Hand::Left(self.as_u16()), 42 | right: Hand::Right(rhs.as_u16()), 43 | result: (self == rhs).into(), 44 | } 45 | } 46 | 47 | fn is_ne(&self, rhs: &StatusCode) -> Self::Assertion { 48 | Assertion { 49 | predicate: Predicate::IsNot, 50 | part: Part::StatusCode, 51 | left: Hand::Left(self.as_u16()), 52 | right: Hand::Right(rhs.as_u16()), 53 | result: (self != rhs).into(), 54 | } 55 | } 56 | } 57 | 58 | impl RangeInclusive for StatusCode { 59 | type Assertion = Assertion; 60 | 61 | fn in_range(&self, min: &StatusCode, max: &StatusCode) -> Self::Assertion { 62 | let lhs = self.as_u16(); 63 | let (min, max) = (min.as_u16(), max.as_u16()); 64 | let result = lhs >= min && lhs <= max; 65 | 66 | Assertion { 67 | predicate: Predicate::Between, 68 | part: Part::StatusCode, 69 | left: Hand::Left(lhs), 70 | right: Hand::Compound(min, max), 71 | result: result.into(), 72 | } 73 | } 74 | } 75 | 76 | impl RangeInclusive for StatusCode { 77 | type Assertion = Assertion; 78 | 79 | fn in_range(&self, min: &u16, max: &u16) -> Self::Assertion { 80 | let lhs = self.as_u16(); 81 | let result = &lhs >= min && &lhs <= max; 82 | 83 | Assertion { 84 | predicate: Predicate::Between, 85 | part: Part::StatusCode, 86 | left: Hand::Left(lhs), 87 | right: Hand::Compound(*min, *max), 88 | result: result.into(), 89 | } 90 | } 91 | } 92 | 93 | #[cfg(test)] 94 | pub mod tests { 95 | use http::StatusCode; 96 | 97 | use crate::assertion::traits::{Equality, RangeInclusive}; 98 | 99 | #[test] 100 | fn impl_is_eq_status_code() { 101 | let assertion = StatusCode::FORBIDDEN.is_eq(&StatusCode::FORBIDDEN); 102 | assert!(assertion.passed(), "{}", assertion.log()) 103 | } 104 | 105 | #[test] 106 | fn impl_is_eq_u16() { 107 | let assertion = StatusCode::FORBIDDEN.is_eq(&403); 108 | assert!(assertion.passed(), "{}", assertion.log()) 109 | } 110 | 111 | #[test] 112 | fn impl_is_not_status_code() { 113 | let assertion = StatusCode::FORBIDDEN.is_ne(&StatusCode::OK); 114 | assert!(assertion.passed(), "{}", assertion.log()) 115 | } 116 | 117 | #[test] 118 | fn impl_is_not_u16() { 119 | let assertion = StatusCode::FORBIDDEN.is_ne(&200); 120 | assert!(assertion.passed(), "{}", assertion.log()) 121 | } 122 | 123 | #[test] 124 | fn impl_is_between_status_code() { 125 | let assertion = 126 | StatusCode::FORBIDDEN.in_range(&StatusCode::BAD_REQUEST, &StatusCode::NOT_FOUND); 127 | 128 | assert!(assertion.passed(), "{}", assertion.log()) 129 | } 130 | 131 | #[test] 132 | fn impl_is_between_u16() { 133 | assert!(StatusCode::FORBIDDEN.in_range(&400, &404).passed()) 134 | } 135 | 136 | mod serialization { 137 | use crate::assertion::Hand; 138 | 139 | use super::*; 140 | use serde_json::json; 141 | 142 | #[test] 143 | fn it_serializes_status_should_be() { 144 | let status = StatusCode::UNAUTHORIZED; 145 | 146 | let expected_json = json!({ 147 | "part": "status code", 148 | "predicate": "should be", 149 | "left": status.as_u16(), 150 | "right": 401, 151 | "result": "passed" 152 | }); 153 | 154 | let assertion = status.is_eq(&401); 155 | 156 | assert_eq!( 157 | json!(assertion), 158 | expected_json, 159 | "Serialized assertion is not equals to the expected json", 160 | ); 161 | } 162 | 163 | #[test] 164 | fn it_serializes_status_should_not_be() { 165 | let status = StatusCode::UNAUTHORIZED; 166 | 167 | let expected_json = json!({ 168 | "part": "status code", 169 | "predicate": "should not be", 170 | "left": status.as_u16(), 171 | "right": 404, 172 | "result": "passed" 173 | }); 174 | 175 | let assertion = status.is_ne(&404); 176 | 177 | assert_eq!( 178 | json!(assertion), 179 | expected_json, 180 | "Serialized assertion is not equals to the expected json", 181 | ); 182 | } 183 | 184 | #[test] 185 | fn it_serializes_status_is_between() { 186 | let status = StatusCode::UNAUTHORIZED; 187 | 188 | let expected_json = json!({ 189 | "part": "status code", 190 | "predicate": "should be between", 191 | "left": status.as_u16(), 192 | "right": Hand::Compound(400, 404), 193 | "result": "passed" 194 | }); 195 | 196 | let assertion = status.in_range(&400, &404); 197 | 198 | assert_eq!( 199 | json!(assertion), 200 | expected_json, 201 | "Serialized assertion is not equals to the expected json", 202 | ); 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/assertion/impls/time.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | assertion::{traits::LessThan, Assertion, Hand}, 3 | dsl::{Part, Predicate}, 4 | }; 5 | 6 | impl LessThan for u64 { 7 | type Assertion = Assertion; 8 | 9 | fn less_than(&self, other: &u64) -> Self::Assertion { 10 | let result = self < other; 11 | 12 | Assertion { 13 | part: Part::ResponseTime, 14 | predicate: Predicate::LessThan, 15 | left: Hand::Left(*self), 16 | right: Hand::Right(*other), 17 | result: result.into(), 18 | } 19 | } 20 | } 21 | 22 | #[cfg(test)] 23 | mod tests { 24 | use crate::assertion::traits::LessThan; 25 | 26 | #[test] 27 | fn it_should_be_less_than() { 28 | let assertion = 20_u64.less_than(&30); 29 | assert!(assertion.passed(), "{}", assertion.log()); 30 | } 31 | 32 | #[test] 33 | fn it_should_not_be_less_than() { 34 | let assertion = u64::MAX.less_than(&30); 35 | assert!(assertion.failed(), "{}", assertion.log()); 36 | } 37 | 38 | mod serialization { 39 | use super::*; 40 | use serde_json::json; 41 | 42 | #[test] 43 | fn it_serializes_time_less_than() { 44 | let expected_json = json!({ 45 | "part": "response time", 46 | "predicate": "should be less than", 47 | "left": 200_u64, 48 | "right": 300_u64, 49 | "result": "passed" 50 | }); 51 | 52 | let assertion = 200.less_than(&300); 53 | 54 | assert_eq!( 55 | json!(assertion), 56 | expected_json, 57 | "Serialized assertion is not equals to the expected json", 58 | ); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/assertion/mod.rs: -------------------------------------------------------------------------------- 1 | //! Functionnality for asserting. 2 | //! 3 | //! This module contains a set of structures, types and implementations to 4 | //! create expressive assertions decoupled from the DSL. This is ideal for 5 | //! external implementations. 6 | //! 7 | //! This is not generally used by end users, instead the [`dsl`] module should 8 | //! provide the built-in functions served as part of the library. 9 | //! 10 | //! The left and right hands of an [`Assertion`] enforce the implementation of 11 | //! [`Debug`] and [`Serialize`]. This is because the library can produce 12 | //! different types of logs to standard output : human-readable 13 | //! (debuggable) and json formats. 14 | //! 15 | //! [`dsl`]: crate::dsl 16 | 17 | mod impls; 18 | #[allow(clippy::wrong_self_convention)] 19 | pub mod traits; 20 | 21 | use crate::{ 22 | dsl::{Part, Predicate}, 23 | grillon::LogSettings, 24 | }; 25 | use serde::Serialize; 26 | use serde_json::{json, Value}; 27 | use std::any::Any; 28 | use std::fmt::Debug; 29 | use strum::Display; 30 | 31 | /// Short-hand types and aliases used for assertions. 32 | pub mod types { 33 | use http::{header::HeaderName, HeaderValue}; 34 | 35 | /// An alias to manipulate an internal representation of headers as tuples 36 | /// of strings. 37 | pub type Headers = Vec<(String, String)>; 38 | /// An alias to manipulate an internal representation of headers as tuples 39 | /// of [`HeaderName`] and [`HeaderValue`]. 40 | pub type HeaderTupleVec = Vec<(HeaderName, HeaderValue)>; 41 | /// An alias to manipulate an internal representation of headers as tuples 42 | /// of str. 43 | pub type HeaderStrTupleVec = Vec<(&'static str, &'static str)>; 44 | /// An alias to manipulate an internal representation of a header as a 45 | /// `String`. 46 | pub type Header = String; 47 | } 48 | 49 | /// Represents left or right hands in an [`Assertion`]. 50 | #[derive(Serialize, Debug)] 51 | #[serde(untagged)] 52 | pub enum Hand 53 | where 54 | T: Debug, 55 | { 56 | /// The left hand of the assertion. 57 | Left(T), 58 | /// The right hand of the assertion. 59 | Right(T), 60 | /// A hand composed of two elements. 61 | Compound(T, T), 62 | /// An empty hand 63 | Empty, 64 | } 65 | 66 | /// The assertion encapsulating information about the [`Part`] under 67 | /// test, the [`Predicate`] used, the [`AssertionResult`] and the right and left 68 | /// [`Hand`]s. 69 | #[derive(Serialize, Debug)] 70 | pub struct Assertion 71 | where 72 | T: Debug + Serialize, 73 | { 74 | /// The part under test. 75 | pub part: Part, 76 | /// The predicate applied in the test. 77 | pub predicate: Predicate, 78 | /// The left hand of the assertion. 79 | pub left: Hand, 80 | /// The right hand of the assertion. 81 | pub right: Hand, 82 | /// The assertion result. 83 | pub result: AssertionResult, 84 | } 85 | 86 | /// Unprocessable event reason. This enum should 87 | /// be used when the assertion syntax is correct 88 | /// but the implementor is unable to process the 89 | /// assertion due to an unexpected event. 90 | /// 91 | /// For example, when an implementation asserts 92 | /// that a word exists in a file but there is no 93 | /// read access. In this case, the assertion 94 | /// fails not because the word is missing, but 95 | /// because the file content cannot be 96 | /// processed. 97 | #[derive(Serialize, Debug)] 98 | #[serde(rename_all = "snake_case")] 99 | pub enum UnprocessableReason { 100 | /// Unprocessable json path with the string representation of the path. 101 | InvalidJsonPath(String), 102 | /// Unprocessable json body because it's missing. 103 | MissingJsonBody, 104 | /// Unprocessable header value because the correspond header key is missing. 105 | MissingHeader, 106 | /// Unprocessable json schema. 107 | InvalidJsonSchema(String, String), 108 | /// Serialization failure. 109 | SerializationFailure(String), 110 | /// Invalid HTTP request headers. 111 | InvalidHttpRequestHeaders(String), 112 | /// Invalid HTTP header value. 113 | InvalidHeaderValue(String), 114 | /// Invalid regex pattern. 115 | InvalidRegex(String), 116 | /// If the HTTP request results in an error while sending request, redirect 117 | /// loop was detected or redirect limit was exhausted. 118 | HttpRequestFailure(String), 119 | /// Unprocessable entity. 120 | Other(String), 121 | } 122 | 123 | // Strum cannot be used here since sum type fields are 124 | // not supported yet just like positional arguments for 125 | // tuple variants. 126 | impl std::fmt::Display for UnprocessableReason { 127 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 128 | match self { 129 | UnprocessableReason::InvalidJsonPath(message) => { 130 | write!(f, "Unprocessable json path: {message}") 131 | } 132 | UnprocessableReason::MissingJsonBody => { 133 | write!(f, "Unprocessable json body: missing") 134 | } 135 | UnprocessableReason::MissingHeader => { 136 | write!(f, "Unprocessable header: header key is missing") 137 | } 138 | UnprocessableReason::InvalidJsonSchema(schema, instance) => { 139 | write!(f, "Invalid json schema: {schema} => {instance}") 140 | } 141 | UnprocessableReason::SerializationFailure(message) => { 142 | write!(f, "Serialization failure: {message}") 143 | } 144 | UnprocessableReason::InvalidHttpRequestHeaders(details) => { 145 | write!(f, "Invalid HTTP request headers: {details}") 146 | } 147 | UnprocessableReason::InvalidHeaderValue(details) => { 148 | write!(f, "Invalid HTTP response header value: {details}") 149 | } 150 | UnprocessableReason::InvalidRegex(regex) => { 151 | write!(f, "Invalid regex pattern: {regex}") 152 | } 153 | UnprocessableReason::HttpRequestFailure(details) => { 154 | write!(f, "Http request failure: {details}") 155 | } 156 | UnprocessableReason::Other(message) => write!(f, "{message}"), 157 | } 158 | } 159 | } 160 | 161 | /// The assertion's result. 162 | #[derive(Serialize, Display, Debug)] 163 | #[serde(rename_all = "snake_case")] 164 | #[strum(serialize_all = "snake_case")] 165 | pub enum AssertionResult { 166 | /// When the assertion passed. 167 | Passed, 168 | /// When the assertion failed. 169 | Failed, 170 | /// When the assertion didn't start. 171 | NotYetStarted, 172 | /// When the assertion is correct but cannot be processed 173 | /// due to an unexpected reason. 174 | Unprocessable(UnprocessableReason), 175 | } 176 | 177 | /// Represents an assertion log. 178 | /// 179 | /// A log is built according to this scheme: 180 | /// - part: \ \[compound_hand_part] 181 | /// - \: \ 182 | /// - was: \ (only in case of failure) 183 | /// 184 | /// The log will be displayed for both [`LogSettings::StdOutput`] and 185 | /// [`LogSettings::StdAssert`] 186 | pub struct AssertionLog(String); 187 | 188 | impl AssertionLog { 189 | /// Builds the assertion message based on the [`Predicate`], the [`Part`] 190 | /// and the [`AssertionResult`]. 191 | pub fn new(assertion: &Assertion) -> Self { 192 | if let AssertionResult::Unprocessable(reason) = &assertion.result { 193 | return Self(format!("{reason}")); 194 | } 195 | 196 | match assertion.part { 197 | Part::JsonPath => Self::jsonpath_log(assertion), 198 | _ => Self::log(assertion), 199 | } 200 | } 201 | 202 | fn log(assertion: &Assertion) -> Self { 203 | let predicate = &assertion.predicate; 204 | let part = &assertion.part; 205 | 206 | let left = match &assertion.left { 207 | Hand::Left(left) => format!("{left:#?}"), 208 | Hand::Compound(left, right) if part == &Part::StatusCode => { 209 | format!("{left:#?} and {right:#?}") 210 | } 211 | _ => "Unexpected left hand in right hand".to_string(), 212 | }; 213 | let right = match &assertion.right { 214 | Hand::Right(right) => format!("{right:#?}"), 215 | Hand::Compound(left, right) if part == &Part::StatusCode => { 216 | format!("{left:#?} and {right:#?}") 217 | } 218 | _ => "Unexpected left hand in right hand".to_string(), 219 | }; 220 | 221 | let result = &assertion.result; 222 | let part = format!("part: {part}"); 223 | let message = match result { 224 | AssertionResult::Passed => format!( 225 | "result: {result} 226 | {part} 227 | {predicate}: {right}" 228 | ), 229 | AssertionResult::Failed => format!( 230 | "result: {result} 231 | {part} 232 | {predicate}: {right} 233 | was: {left}" 234 | ), 235 | AssertionResult::NotYetStarted => format!("Not yet started : {part}"), 236 | AssertionResult::Unprocessable(reason) => format!("{reason}"), 237 | }; 238 | 239 | Self(message) 240 | } 241 | 242 | fn jsonpath_log(assertion: &Assertion) -> Self { 243 | let predicate = &assertion.predicate; 244 | let part = &assertion.part; 245 | 246 | let left_hand = match &assertion.left { 247 | Hand::Compound(left, right) if part == &Part::JsonPath => (left, right), 248 | _ => return Self("".to_string()), 249 | }; 250 | let right_hand = match &assertion.right { 251 | Hand::Right(right) if part == &Part::JsonPath => right, 252 | _ => return Self("".to_string()), 253 | }; 254 | 255 | let jsonpath = left_hand.0; 256 | #[allow(trivial_casts)] 257 | let jsonpath = match (jsonpath as &dyn Any).downcast_ref::() { 258 | Some(Value::String(jsonpath_string)) => jsonpath_string.to_string(), 259 | _ => format!("{jsonpath:?}"), 260 | }; 261 | 262 | let jsonpath_value = left_hand.1; 263 | 264 | let result = &assertion.result; 265 | let part = format!("part: {part} '{jsonpath}'"); 266 | let message = match result { 267 | AssertionResult::Passed => format!( 268 | "result: {result} 269 | {part} 270 | {predicate}: {right_hand:#?}" 271 | ), 272 | AssertionResult::Failed => format!( 273 | "result: {result} 274 | {part} 275 | {predicate}: {right_hand:#?} 276 | was: {jsonpath_value:#?}" 277 | ), 278 | AssertionResult::NotYetStarted => format!("[Not yet started] {part}"), 279 | AssertionResult::Unprocessable(reason) => format!("{reason}"), 280 | }; 281 | 282 | Self(message) 283 | } 284 | } 285 | 286 | impl Assertion 287 | where 288 | T: Debug + Serialize + 'static, 289 | { 290 | /// Returns if the assertion passed. 291 | pub fn passed(&self) -> bool { 292 | matches!(self.result, AssertionResult::Passed) 293 | } 294 | 295 | /// Returns if the assertion failed. 296 | pub fn failed(&self) -> bool { 297 | matches!( 298 | self.result, 299 | AssertionResult::Failed | AssertionResult::Unprocessable(_) 300 | ) 301 | } 302 | 303 | /// Runs the assertion and produce the the result results with the given 304 | /// [`LogSettings`]. 305 | pub fn assert(self, log_settings: &LogSettings) -> Assertion { 306 | let message = self.log(); 307 | match log_settings { 308 | LogSettings::StdOutput => println!("\n{message}"), 309 | LogSettings::StdAssert => assert!(self.passed(), "\n\n{message}"), 310 | LogSettings::JsonOutput => { 311 | let json = serde_json::to_string(&json!(self)) 312 | .expect("Unexpected json failure: failed to serialize assertion"); 313 | println!("{json}"); 314 | } 315 | } 316 | 317 | self 318 | } 319 | 320 | fn log(&self) -> String { 321 | AssertionLog::new(self).0 322 | } 323 | } 324 | 325 | impl From for AssertionResult { 326 | fn from(val: bool) -> Self { 327 | if val { 328 | return AssertionResult::Passed; 329 | } 330 | 331 | AssertionResult::Failed 332 | } 333 | } 334 | 335 | #[cfg(test)] 336 | mod tests { 337 | use super::{AssertionResult, Hand}; 338 | use crate::dsl::Predicate::{Between, LessThan}; 339 | use crate::{assertion::Assertion, dsl::Part}; 340 | use serde_json::json; 341 | 342 | #[test] 343 | fn it_should_serialize_status_code() { 344 | let assertion: Assertion = Assertion { 345 | part: Part::StatusCode, 346 | predicate: Between, 347 | left: Hand::Left(200), 348 | right: Hand::Compound(200, 299), 349 | result: AssertionResult::Passed, 350 | }; 351 | 352 | let expected_json = json!({ 353 | "part": "status code", 354 | "predicate": "should be between", 355 | "left": 200, 356 | "right": [200, 299], 357 | "result": "passed" 358 | }); 359 | 360 | assert_eq!(json!(assertion), expected_json); 361 | } 362 | 363 | #[test] 364 | fn it_should_serialize_failed_response_time() { 365 | let assertion: Assertion = Assertion { 366 | part: Part::ResponseTime, 367 | predicate: LessThan, 368 | left: Hand::Left(300), 369 | right: Hand::Right(248), 370 | result: AssertionResult::Failed, 371 | }; 372 | 373 | let expected_json = json!({ 374 | "part": "response time", 375 | "predicate": "should be less than", 376 | "left": 300, 377 | "right": 248, 378 | "result": "failed" 379 | }); 380 | 381 | assert_eq!(json!(assertion), expected_json); 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /src/assertion/traits.rs: -------------------------------------------------------------------------------- 1 | //! This module contains various traits for comparisons, range checks and inner 2 | //! checks. 3 | 4 | /// Trait to test the equality between two values. 5 | pub trait Equality { 6 | /// The resulting assertion after applying the equality test. 7 | type Assertion; 8 | 9 | /// Asserts the equality. 10 | fn is_eq(&self, rhs: &Rhs) -> Self::Assertion; 11 | 12 | /// Asserts the non equality. 13 | fn is_ne(&self, rhs: &Rhs) -> Self::Assertion; 14 | } 15 | 16 | /// Trait to test if a value is withing an inclusive range. 17 | pub trait RangeInclusive { 18 | /// The resulting assertion after applying the inclusive range test. 19 | type Assertion; 20 | 21 | /// Asserts the value is in open range. 22 | fn in_range(&self, min: &T, max: &T) -> Self::Assertion; 23 | } 24 | 25 | /// Trait to test if a value is less than the other. 26 | pub trait LessThan { 27 | /// The resulting assertion after applying the less than test. 28 | type Assertion; 29 | 30 | /// Asserts the value is in open range. 31 | fn less_than(&self, other: &T) -> Self::Assertion; 32 | } 33 | 34 | /// A representation of a container of items where we can perform inner checks 35 | /// with `has` and `has_not` functions. 36 | pub trait Container { 37 | /// The resulting assertion after applying the contains test. 38 | type Assertion; 39 | 40 | /// Asserts that the container contains other. 41 | fn has(&self, other: &T) -> Self::Assertion; 42 | 43 | /// Asserts that the container does not contain other. 44 | fn has_not(&self, other: &T) -> Self::Assertion; 45 | } 46 | 47 | /// Trait to test if a json value matches the json schema. 48 | pub trait JsonSchema { 49 | /// The resulting assertion after applying the json schema test. 50 | type Assertion; 51 | 52 | /// Asserts that the json value matches the given schema. 53 | fn matches_schema(&self, other: &T) -> Self::Assertion; 54 | } 55 | 56 | /// Trait to test if a json value matches the regex. 57 | pub trait Matching { 58 | /// The resulting assertion after applying the match test. 59 | type Assertion; 60 | 61 | /// Asserts that the json value matches the given regex. 62 | fn is_match(&self, other: &T) -> Self::Assertion; 63 | 64 | /// Asserts that the json value doesn't match the given regex. 65 | fn is_not_match(&self, other: &T) -> Self::Assertion; 66 | } 67 | -------------------------------------------------------------------------------- /src/dsl/expression.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use strum::Display; 3 | 4 | /// Type representing a condition for assertions. 5 | /// 6 | /// [`Predicate`]s are used in the various DSL modules to apply conditions 7 | /// in assertions in a declarative way. A [`Predicate`] is used via an 8 | /// [`Expression`]. 9 | #[derive(Display, Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] 10 | pub enum Predicate { 11 | /// Actual should be equals (strictly) to expected. 12 | #[strum(serialize = "should be")] 13 | #[serde(rename = "should be")] 14 | Is, 15 | /// Actual should not be equals (strictly) to expected. 16 | #[strum(serialize = "should not be")] 17 | #[serde(rename = "should not be")] 18 | IsNot, 19 | /// Actual should contain expected. 20 | #[strum(serialize = "should contain")] 21 | #[serde(rename = "should contain")] 22 | Contains, 23 | /// Actual should not contain expected. 24 | #[strum(serialize = "should not contain")] 25 | #[serde(rename = "should not contain")] 26 | DoesNotContain, 27 | /// Actual should match the regex. 28 | #[strum(serialize = "should match")] 29 | #[serde(rename = "should match")] 30 | Matches, 31 | /// Actual should not match the regex. 32 | #[strum(serialize = "should not match")] 33 | #[serde(rename = "should not match")] 34 | DoesNotMatch, 35 | /// Actual should be less than expected. 36 | #[strum(serialize = "should be less than")] 37 | #[serde(rename = "should be less than")] 38 | LessThan, 39 | /// Actual should be between the given closed interval [min, max]. 40 | #[strum(serialize = "should be between")] 41 | #[serde(rename = "should be between")] 42 | Between, 43 | /// Actual should match the given json schema. 44 | #[strum(serialize = "should match schema")] 45 | #[serde(rename = "should match schema")] 46 | Schema, 47 | /// The absence of predicate for an assertion. 48 | /// Usually used for an unprocessable assertion. 49 | #[strum(serialize = "none")] 50 | #[serde(rename = "none")] 51 | NoPredicate, 52 | } 53 | 54 | /// Represents a range starting with `left` and ending with `right`. 55 | /// 56 | /// This type does not assume if it is a closed, open or half-closed/open interval. 57 | /// Its use will determine it. 58 | #[derive(Deserialize, Debug, PartialEq, Eq)] 59 | pub struct Range { 60 | /// The left value of the range. 61 | pub left: T, 62 | /// The right value of the range. 63 | pub right: T, 64 | } 65 | 66 | /// Represents a regex wrapper. 67 | #[derive(Deserialize, Debug, PartialEq, Eq)] 68 | pub struct RegexWrapper(pub T); 69 | 70 | /// Represents an expected `value` associated to a [`Predicate`] to run against 71 | /// another `value`. 72 | /// 73 | /// An expression is used to build assertions. It is composed of a [`Predicate`] 74 | /// and an expected `value` that will be used to create expressive assertion 75 | /// functions like this one : `status(is_between(200, 204))`. In this example we 76 | /// assert that the actual [`StatusCode`] is [`Between`] a closed [`Range`]. 77 | /// 78 | /// [`Between`]: Predicate::Between 79 | /// [`StatusCode`]: crate::StatusCode 80 | #[derive(Deserialize, Debug, PartialEq, Eq)] 81 | pub struct Expression { 82 | /// The [`Predicate`] to apply in an assertion. 83 | pub predicate: Predicate, 84 | /// The expected value as part of the [`Predicate`]. 85 | pub value: T, 86 | } 87 | 88 | /// Macro to generate assertion functions that return an [`Expression`]. 89 | macro_rules! predicate { 90 | ($(#[$meta:meta])* $name:ident, $o:expr) => { 91 | $(#[$meta])* 92 | pub fn $name(value: T) -> Expression { 93 | Expression { 94 | predicate: $o, 95 | value, 96 | } 97 | } 98 | }; 99 | } 100 | 101 | /// Creates an expression to assert the actual value is in the closed interval [min, max]. 102 | pub fn is_between(min: T, max: T) -> Expression> { 103 | Expression { 104 | predicate: Predicate::Between, 105 | value: Range { 106 | left: min, 107 | right: max, 108 | }, 109 | } 110 | } 111 | 112 | /// Creates an expression to assert the actual value matches the regex. 113 | pub fn matches(re: T) -> Expression> { 114 | Expression { 115 | predicate: Predicate::Matches, 116 | value: RegexWrapper(re), 117 | } 118 | } 119 | 120 | /// Creates an expression to assert the actual value doesn't match the regex. 121 | pub fn does_not_match(re: T) -> Expression> { 122 | Expression { 123 | predicate: Predicate::DoesNotMatch, 124 | value: RegexWrapper(re), 125 | } 126 | } 127 | 128 | predicate!( 129 | /// Creates an expression to assert that the actual value is strictly equal to the expected one. 130 | is, 131 | Predicate::Is 132 | ); 133 | predicate!( 134 | /// Creates an expression to assert that the actual value is strictly not equal to the expected one. 135 | is_not, 136 | Predicate::IsNot 137 | ); 138 | predicate!( 139 | /// Creates an expression to assert that the actual value contains the expected one. 140 | contains, 141 | Predicate::Contains 142 | ); 143 | predicate!( 144 | /// Creates an expression to assert that the actual value does not contain the expected one. 145 | does_not_contain, 146 | Predicate::DoesNotContain 147 | ); 148 | predicate!( 149 | /// Creates an expression to assert that the actual value is inferior to the provided value. 150 | is_less_than, 151 | Predicate::LessThan 152 | ); 153 | predicate!( 154 | /// Creates an expression to assert that the actual value matches the json schema. 155 | schema, 156 | Predicate::Schema 157 | ); 158 | 159 | #[cfg(test)] 160 | mod tests { 161 | use super::{Expression, Predicate, Range}; 162 | use serde_json::Value; 163 | use test_case::test_case; 164 | 165 | #[test_case(Value::String(String::from("should be")), Predicate::Is; "Failed to deserialize predicate Is")] 166 | #[test_case(Value::String(String::from("should not be")), Predicate::IsNot; "Failed to deserialize predicate IsNot")] 167 | #[test_case(Value::String(String::from("should contain")), Predicate::Contains; "Failed to deserialize predicate Contains")] 168 | #[test_case(Value::String(String::from("should not contain")), Predicate::DoesNotContain; "Failed to deserialize predicate DoesNotContain")] 169 | #[test_case(Value::String(String::from("should match")), Predicate::Matches; "Failed to deserialize predicate Matches")] 170 | #[test_case(Value::String(String::from("should not match")), Predicate::DoesNotMatch; "Failed to deserialize predicate DoesNotMatch")] 171 | #[test_case(Value::String(String::from("should be less than")), Predicate::LessThan; "Failed to deserialize predicate LessThan")] 172 | #[test_case(Value::String(String::from("should be between")), Predicate::Between; "Failed to deserialize predicate Between")] 173 | #[test_case(Value::String(String::from("should match schema")), Predicate::Schema; "Failed to deserialize predicate Schema")] 174 | 175 | fn deser_predicates(json_predicate: Value, predicate: Predicate) { 176 | assert_eq!( 177 | serde_json::from_value::(json_predicate).unwrap(), 178 | predicate 179 | ) 180 | } 181 | 182 | #[test] 183 | fn deser_expression() { 184 | let json = serde_json::json!({ 185 | "predicate": "should be between", 186 | "value": { 187 | "left": 200, 188 | "right": 299 189 | } 190 | }); 191 | 192 | let expr: Expression> = serde_json::from_value(json).unwrap(); 193 | 194 | assert_eq!( 195 | expr, 196 | Expression { 197 | predicate: Predicate::Between, 198 | value: Range { 199 | left: 200, 200 | right: 299 201 | } 202 | } 203 | ); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/dsl/http/body.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::{ 4 | assertion::traits::{Equality, JsonSchema}, 5 | assertion::Assertion, 6 | dsl::expression::Predicate::{self, Is, IsNot, Schema}, 7 | LogSettings, 8 | }; 9 | use serde_json::Value; 10 | 11 | /// Http json body DSL to assert body of a response. 12 | pub trait JsonBodyDsl { 13 | /// Asserts that the json response body is strictly equals to the provided value. 14 | fn is(&self, actual: T) -> Assertion; 15 | /// Asserts that the json response body is strictly not equals to the provided value. 16 | fn is_not(&self, actual: T) -> Assertion; 17 | /// Asserts that the json response body matches the json schema. 18 | fn schema(&self, schema: T) -> Assertion; 19 | /// Evaluates the json body assertion to run based on the [`Predicate`]. 20 | fn eval( 21 | &self, 22 | actual: T, 23 | predicate: Predicate, 24 | log_settings: &LogSettings, 25 | ) -> Assertion { 26 | match predicate { 27 | Is => self.is(actual).assert(log_settings), 28 | IsNot => self.is_not(actual).assert(log_settings), 29 | Schema => self.schema(actual).assert(log_settings), 30 | _ => unimplemented!("Invalid predicate for the json body DSL: {predicate}"), 31 | } 32 | } 33 | } 34 | 35 | impl JsonBodyDsl for Value { 36 | fn is(&self, actual: Value) -> Assertion { 37 | actual.is_eq(self) 38 | } 39 | 40 | fn is_not(&self, actual: Value) -> Assertion { 41 | actual.is_ne(self) 42 | } 43 | 44 | fn schema(&self, actual: Value) -> Assertion { 45 | actual.matches_schema(self) 46 | } 47 | } 48 | 49 | impl JsonBodyDsl for &str { 50 | fn is(&self, actual: Value) -> Assertion { 51 | actual.is_eq(*self) 52 | } 53 | 54 | fn is_not(&self, actual: Value) -> Assertion { 55 | actual.is_ne(*self) 56 | } 57 | 58 | fn schema(&self, actual: Value) -> Assertion { 59 | actual.matches_schema(*self) 60 | } 61 | } 62 | 63 | impl JsonBodyDsl for String { 64 | fn is(&self, actual: Value) -> Assertion { 65 | actual.is_eq(self) 66 | } 67 | 68 | fn is_not(&self, actual: Value) -> Assertion { 69 | actual.is_ne(self) 70 | } 71 | 72 | fn schema(&self, actual: Value) -> Assertion { 73 | actual.matches_schema(self) 74 | } 75 | } 76 | 77 | impl JsonBodyDsl for PathBuf { 78 | fn is(&self, actual: Value) -> Assertion { 79 | actual.is_eq(self) 80 | } 81 | 82 | fn is_not(&self, actual: Value) -> Assertion { 83 | actual.is_ne(self) 84 | } 85 | 86 | fn schema(&self, actual: Value) -> Assertion { 87 | actual.matches_schema(self) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/dsl/http/header.rs: -------------------------------------------------------------------------------- 1 | use http::HeaderValue; 2 | 3 | use crate::{ 4 | assertion::{traits::Equality, types::Header, Assertion}, 5 | dsl::expression::Predicate, 6 | LogSettings, 7 | }; 8 | 9 | /// Http header DSL to assert a single header from a response. 10 | pub trait HeaderDsl { 11 | /// Asserts the header is strictly equal to the provided ones. 12 | fn is(&self, actual: T) -> Assertion
; 13 | /// Asserts the header is strictly not equal to the provided ones. 14 | fn is_not(&self, actual: T) -> Assertion
; 15 | /// Evaluates the header assertion to run based on the [`Predicate`]. 16 | fn eval( 17 | &self, 18 | actual: T, 19 | predicate: Predicate, 20 | log_settings: &LogSettings, 21 | ) -> Assertion
{ 22 | match predicate { 23 | Predicate::Is => self.is(actual).assert(log_settings), 24 | Predicate::IsNot => self.is_not(actual).assert(log_settings), 25 | _ => unimplemented!("Invalid predicate for the header DSL: {predicate}"), 26 | } 27 | } 28 | } 29 | 30 | impl HeaderDsl for &str { 31 | fn is(&self, actual: HeaderValue) -> Assertion
{ 32 | actual.is_eq(self) 33 | } 34 | 35 | fn is_not(&self, actual: HeaderValue) -> Assertion
{ 36 | actual.is_ne(self) 37 | } 38 | } 39 | 40 | impl HeaderDsl for String { 41 | fn is(&self, actual: HeaderValue) -> Assertion
{ 42 | actual.is_eq(self) 43 | } 44 | 45 | fn is_not(&self, actual: HeaderValue) -> Assertion
{ 46 | actual.is_ne(self) 47 | } 48 | } 49 | 50 | impl HeaderDsl for HeaderValue { 51 | fn is(&self, actual: HeaderValue) -> Assertion
{ 52 | actual.is_eq(self) 53 | } 54 | 55 | fn is_not(&self, actual: HeaderValue) -> Assertion
{ 56 | actual.is_ne(self) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/dsl/http/headers.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | assertion::{ 3 | traits::{Container, Equality}, 4 | types::Headers, 5 | Assertion, 6 | }, 7 | dsl::expression::Predicate, 8 | header::{HeaderMap, HeaderName, HeaderValue}, 9 | LogSettings, 10 | }; 11 | 12 | // TODO: see to use the low-level types 13 | type HeadersVec = Vec<(HeaderName, HeaderValue)>; 14 | type HeadersStrVec = Vec<(&'static str, &'static str)>; 15 | 16 | /// Http header DSL to assert a specific header of the response. 17 | pub trait HeadersDsl { 18 | /// Asserts the headers are strictly equal to the provided ones. 19 | fn is(&self, actual: T) -> Assertion; 20 | /// Asserts the headers are strictly not equal to the provided ones. 21 | fn is_not(&self, actual: T) -> Assertion; 22 | /// Asserts the headers contain a specific header by key - value. 23 | fn contains(&self, actual: T) -> Assertion; 24 | /// Asserts the headers does not contain a specific header by key - value. 25 | fn does_not_contain(&self, actual: T) -> Assertion; 26 | /// Evaluates the headers assertion to run based on the [`Predicate`]. 27 | fn eval( 28 | &self, 29 | actual: T, 30 | predicate: Predicate, 31 | log_settings: &LogSettings, 32 | ) -> Assertion { 33 | match predicate { 34 | Predicate::Is => self.is(actual).assert(log_settings), 35 | Predicate::IsNot => self.is_not(actual).assert(log_settings), 36 | Predicate::Contains => self.contains(actual).assert(log_settings), 37 | Predicate::DoesNotContain => self.does_not_contain(actual).assert(log_settings), 38 | _ => unimplemented!("Invalid predicate for the headers DSL: {predicate}"), 39 | } 40 | } 41 | } 42 | 43 | impl HeadersDsl for HeaderMap { 44 | fn is(&self, actual: HeaderMap) -> Assertion { 45 | actual.is_eq(self) 46 | } 47 | 48 | fn is_not(&self, actual: HeaderMap) -> Assertion { 49 | actual.is_ne(self) 50 | } 51 | 52 | fn contains(&self, actual: HeaderMap) -> Assertion { 53 | actual.has(self) 54 | } 55 | 56 | fn does_not_contain(&self, actual: HeaderMap) -> Assertion { 57 | actual.has_not(self) 58 | } 59 | } 60 | 61 | impl HeadersDsl for HeadersVec { 62 | fn is(&self, actual: HeaderMap) -> Assertion { 63 | actual.is_eq(self) 64 | } 65 | 66 | fn is_not(&self, actual: HeaderMap) -> Assertion { 67 | actual.is_ne(self) 68 | } 69 | 70 | fn contains(&self, actual: HeaderMap) -> Assertion { 71 | actual.has(self) 72 | } 73 | 74 | fn does_not_contain(&self, actual: HeaderMap) -> Assertion { 75 | actual.has_not(self) 76 | } 77 | } 78 | 79 | impl HeadersDsl for HeadersStrVec { 80 | fn is(&self, actual: HeaderMap) -> Assertion { 81 | actual.is_eq(self) 82 | } 83 | 84 | fn is_not(&self, actual: HeaderMap) -> Assertion { 85 | actual.is_ne(self) 86 | } 87 | 88 | fn contains(&self, actual: HeaderMap) -> Assertion { 89 | actual.has(self) 90 | } 91 | 92 | fn does_not_contain(&self, actual: HeaderMap) -> Assertion { 93 | actual.has_not(self) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/dsl/http/mod.rs: -------------------------------------------------------------------------------- 1 | //! The `http` DSL provides built-in functions and types to perform declarative 2 | //! assertions against an http response. 3 | //! 4 | //! The following example demonstrates some of the assertions we can run against 5 | //! an http response of a specific endpoint. 6 | //! 7 | //! ```rust 8 | //! use grillon::{Result, Grillon, StatusCode, json}; 9 | //! use grillon::dsl::{contains, is, is_less_than, http::is_success}; 10 | //! use grillon::header::{HeaderValue, CONTENT_TYPE}; 11 | //! 12 | //! #[tokio::test] 13 | //! async fn check_users_endpoint() -> Result<()> { 14 | //! Grillon::new("https://jsonplaceholder.typicode.com")? 15 | //! .get("albums/1") 16 | //! .assert() 17 | //! .await 18 | //! .status(is_success()) 19 | //! .headers(contains(vec![ 20 | //! (CONTENT_TYPE, HeaderValue::from_static("application/json; charset=utf-8")) 21 | //! ])) 22 | //! .json_body(is(json!({ 23 | //! "id": 1, 24 | //! "title": "quidem molestiae enim", 25 | //! "userId": 1 26 | //! }))) 27 | //! .response_time(is_less_than(500)); 28 | //! 29 | //! Ok(()) 30 | //! } 31 | 32 | mod body; 33 | mod header; 34 | mod headers; 35 | mod status; 36 | mod time; 37 | 38 | pub use self::body::JsonBodyDsl; 39 | pub use self::header::HeaderDsl; 40 | pub use self::headers::HeadersDsl; 41 | pub use self::status::*; 42 | pub use self::time::TimeDsl; 43 | -------------------------------------------------------------------------------- /src/dsl/http/status.rs: -------------------------------------------------------------------------------- 1 | //! The `http::status` DSL provides built-in functions to perform declarative 2 | //! assertions against the status of an http response. 3 | use crate::{ 4 | assertion::{ 5 | traits::{Equality, RangeInclusive}, 6 | Assertion, 7 | }, 8 | dsl::{is_between, Expression, Predicate, Range}, 9 | grillon::LogSettings, 10 | StatusCode, 11 | }; 12 | 13 | /// A short-hand function to test if the status code 14 | /// of the response is in the range of 2xx codes. 15 | pub fn is_success() -> Expression> { 16 | is_between(200, 299) 17 | } 18 | 19 | /// A short-hand function to test if the status code 20 | /// of the response is in the range of 4xx codes. 21 | pub fn is_client_error() -> Expression> { 22 | is_between(400, 499) 23 | } 24 | 25 | /// A short-hand function to test if the status code 26 | /// of the response is in the range of 5xx codes. 27 | pub fn is_server_error() -> Expression> { 28 | is_between(500, 599) 29 | } 30 | 31 | /// Http status DSL to assert the status code of a response. 32 | /// 33 | /// ```rust 34 | /// use grillon::{Result, Grillon, StatusCode}; 35 | /// use grillon::dsl::{is, is_between, is_not, http::is_success}; 36 | /// 37 | /// #[tokio::test] 38 | /// async fn check_status() -> Result<()> { 39 | /// Grillon::new("https://jsonplaceholder.typicode.com")? 40 | /// .get("users/1") 41 | /// .assert() 42 | /// .await 43 | /// .status(is(200)) 44 | /// .status(is(StatusCode::OK)) 45 | /// .status(is_not(500)) 46 | /// .status(is_not(StatusCode::INTERNAL_SERVER_ERROR)) 47 | /// .status(is_success()) 48 | /// .status(is_between(200, 204)) 49 | /// .status(is_between(StatusCode::OK, StatusCode::NO_CONTENT)); 50 | /// 51 | /// Ok(()) 52 | /// } 53 | pub trait StatusCodeDsl { 54 | /// Evaluates the status assertion to run depending on the [`Predicate`]. 55 | /// The test results will be produced on the given output configured via the 56 | /// [`LogSettings`]. 57 | fn eval(&self, actual: T, predicate: Predicate, log_settings: &LogSettings) -> Assertion; 58 | } 59 | 60 | impl StatusCodeDsl for StatusCode { 61 | fn eval( 62 | &self, 63 | actual: StatusCode, 64 | predicate: Predicate, 65 | log_settings: &LogSettings, 66 | ) -> Assertion { 67 | match predicate { 68 | Predicate::Is => self.is(actual).assert(log_settings), 69 | Predicate::IsNot => self.is_not(actual).assert(log_settings), 70 | _ => unimplemented!("Invalid predicate for the status code DSL: {predicate}"), 71 | } 72 | } 73 | } 74 | 75 | impl StatusCodeDsl for u16 { 76 | fn eval( 77 | &self, 78 | actual: StatusCode, 79 | predicate: Predicate, 80 | log_settings: &LogSettings, 81 | ) -> Assertion { 82 | match predicate { 83 | Predicate::Is => self.is(actual).assert(log_settings), 84 | Predicate::IsNot => self.is_not(actual).assert(log_settings), 85 | _ => unimplemented!("Invalid predicate for the status code DSL: {predicate}"), 86 | } 87 | } 88 | } 89 | 90 | impl StatusCodeDsl for Range { 91 | fn eval( 92 | &self, 93 | actual: StatusCode, 94 | predicate: Predicate, 95 | log_settings: &LogSettings, 96 | ) -> Assertion { 97 | match predicate { 98 | Predicate::Between => self.is_between(actual).assert(log_settings), 99 | _ => unimplemented!("Invalid predicate for the status code DSL: {predicate}"), 100 | } 101 | } 102 | } 103 | 104 | impl StatusCodeDsl for Range { 105 | fn eval( 106 | &self, 107 | actual: StatusCode, 108 | predicate: Predicate, 109 | log_settings: &LogSettings, 110 | ) -> Assertion { 111 | match predicate { 112 | Predicate::Between => self.is_between(actual).assert(log_settings), 113 | _ => unimplemented!("Invalid predicate for the status code DSL: {predicate}"), 114 | } 115 | } 116 | } 117 | 118 | /// Http status DSL to assert the status code equality of a response. 119 | pub trait StatusCodeDslEquality: StatusCodeDsl { 120 | /// Builds an assertion comparing the equality between two status codes. 121 | fn is(&self, actual: T) -> Assertion; 122 | /// Builds an assertion comparing the non equality between two status codes. 123 | fn is_not(&self, actual: T) -> Assertion; 124 | } 125 | 126 | impl StatusCodeDslEquality for StatusCode { 127 | fn is(&self, actual: StatusCode) -> Assertion { 128 | actual.is_eq(self) 129 | } 130 | 131 | fn is_not(&self, actual: StatusCode) -> Assertion { 132 | actual.is_ne(self) 133 | } 134 | } 135 | 136 | impl StatusCodeDslEquality for u16 { 137 | fn is(&self, actual: StatusCode) -> Assertion { 138 | actual.is_eq(self) 139 | } 140 | 141 | fn is_not(&self, actual: StatusCode) -> Assertion { 142 | actual.is_ne(self) 143 | } 144 | } 145 | 146 | /// Http status DSL to assert the status code of a response is in 147 | /// the given inclusive range. 148 | pub trait StatusCodeDslBetween: StatusCodeDsl { 149 | /// Builds an assertion to check if a status code is within an inclusive 150 | /// range. 151 | fn is_between(&self, actual: T) -> Assertion; 152 | } 153 | 154 | impl StatusCodeDslBetween for Range { 155 | fn is_between(&self, actual: StatusCode) -> Assertion { 156 | actual.in_range(&self.left, &self.right) 157 | } 158 | } 159 | 160 | impl StatusCodeDslBetween for Range { 161 | fn is_between(&self, actual: StatusCode) -> Assertion { 162 | actual.in_range(&self.left, &self.right) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/dsl/http/time.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | assertion::{traits::LessThan, Assertion}, 3 | dsl::expression::Predicate, 4 | LogSettings, 5 | }; 6 | 7 | /// Http time DSL to assert the response time. 8 | pub trait TimeDsl { 9 | /// Asserts the response time is strictly inferior to the provided time in 10 | /// milliseconds. 11 | fn is_less_than(&self, actual: T) -> Assertion; 12 | /// Evaluates the time assertion to run based on the [`Predicate`] 13 | fn eval(&self, actual: T, predicate: Predicate, log_settings: &LogSettings) -> Assertion { 14 | match predicate { 15 | Predicate::LessThan => self.is_less_than(actual).assert(log_settings), 16 | _ => unimplemented!("Invalid predicate for the time DSL: {predicate}"), 17 | } 18 | } 19 | } 20 | 21 | impl TimeDsl for u64 { 22 | fn is_less_than(&self, actual: u64) -> Assertion { 23 | actual.less_than(self) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/dsl/json_path.rs: -------------------------------------------------------------------------------- 1 | //! The json path domain-specific language. 2 | 3 | use crate::{ 4 | assertion::{ 5 | traits::{Container, Equality, JsonSchema, Matching}, 6 | Assertion, 7 | }, 8 | dsl::expression::Predicate::{ 9 | self, Contains, DoesNotContain, DoesNotMatch, Is, IsNot, Matches, Schema, 10 | }, 11 | LogSettings, 12 | }; 13 | use serde::{Deserialize, Serialize}; 14 | use serde_json::Value; 15 | use std::path::PathBuf; 16 | 17 | use super::RegexWrapper; 18 | 19 | /// Represents the result of a json path query. 20 | /// 21 | /// This structure is used to wrap the json path result 22 | /// and run assertions against. 23 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] 24 | pub struct JsonPathResult<'p, T> { 25 | /// The path used to run the json path query. 26 | pub path: &'p str, 27 | /// The resulting json value of the json path query. 28 | pub value: T, 29 | } 30 | 31 | impl<'p, T> JsonPathResult<'p, T> { 32 | /// Creates a new instance of `JsonPathResult` that is 33 | /// wrapping the given `path` and the given `value`. 34 | pub fn new(path: &'p str, value: T) -> Self { 35 | Self { path, value } 36 | } 37 | } 38 | 39 | /// Json path DSL to assert a value at a given path. 40 | pub trait JsonPathDsl { 41 | /// Evaluates the status assertion to run depending on the [`Predicate`]. 42 | /// The test results will be produced on the given output configured via the 43 | /// [`LogSettings`]. 44 | fn eval( 45 | &self, 46 | actual: JsonPathResult<'_, Value>, 47 | predicate: Predicate, 48 | log_settings: &LogSettings, 49 | ) -> Assertion; 50 | } 51 | 52 | impl JsonPathDsl for Value { 53 | fn eval( 54 | &self, 55 | jsonpath_res: JsonPathResult<'_, Value>, 56 | predicate: Predicate, 57 | log_settings: &LogSettings, 58 | ) -> Assertion { 59 | match predicate { 60 | Is => self.is(jsonpath_res).assert(log_settings), 61 | IsNot => self.is_not(jsonpath_res).assert(log_settings), 62 | Schema => self.schema(jsonpath_res).assert(log_settings), 63 | Contains => self.contains(jsonpath_res).assert(log_settings), 64 | DoesNotContain => self.does_not_contain(jsonpath_res).assert(log_settings), 65 | _ => unimplemented!("Invalid predicate for the json path DSL: {predicate}"), 66 | } 67 | } 68 | } 69 | 70 | impl JsonPathDsl for String { 71 | fn eval( 72 | &self, 73 | jsonpath_res: JsonPathResult<'_, Value>, 74 | predicate: Predicate, 75 | log_settings: &LogSettings, 76 | ) -> Assertion { 77 | match predicate { 78 | Is => self.is(jsonpath_res).assert(log_settings), 79 | IsNot => self.is_not(jsonpath_res).assert(log_settings), 80 | Schema => self.schema(jsonpath_res).assert(log_settings), 81 | Contains => self.contains(jsonpath_res).assert(log_settings), 82 | DoesNotContain => self.does_not_contain(jsonpath_res).assert(log_settings), 83 | _ => unimplemented!("Invalid predicate for the json path DSL: {predicate}"), 84 | } 85 | } 86 | } 87 | 88 | impl JsonPathDsl for &str { 89 | fn eval( 90 | &self, 91 | jsonpath_res: JsonPathResult<'_, Value>, 92 | predicate: Predicate, 93 | log_settings: &LogSettings, 94 | ) -> Assertion { 95 | match predicate { 96 | Is => self.is(jsonpath_res).assert(log_settings), 97 | IsNot => self.is_not(jsonpath_res).assert(log_settings), 98 | Schema => self.schema(jsonpath_res).assert(log_settings), 99 | Contains => self.contains(jsonpath_res).assert(log_settings), 100 | DoesNotContain => self.does_not_contain(jsonpath_res).assert(log_settings), 101 | _ => unimplemented!("Invalid predicate for the json path DSL: {predicate}"), 102 | } 103 | } 104 | } 105 | 106 | impl JsonPathDsl for PathBuf { 107 | fn eval( 108 | &self, 109 | jsonpath_res: JsonPathResult<'_, Value>, 110 | predicate: Predicate, 111 | log_settings: &LogSettings, 112 | ) -> Assertion { 113 | match predicate { 114 | Is => self.is(jsonpath_res).assert(log_settings), 115 | IsNot => self.is_not(jsonpath_res).assert(log_settings), 116 | Schema => self.schema(jsonpath_res).assert(log_settings), 117 | Contains => self.contains(jsonpath_res).assert(log_settings), 118 | DoesNotContain => self.does_not_contain(jsonpath_res).assert(log_settings), 119 | _ => unimplemented!("Invalid predicate for the json path DSL: {predicate}"), 120 | } 121 | } 122 | } 123 | 124 | impl JsonPathDsl for RegexWrapper { 125 | fn eval( 126 | &self, 127 | jsonpath_res: JsonPathResult<'_, Value>, 128 | predicate: Predicate, 129 | log_settings: &LogSettings, 130 | ) -> Assertion { 131 | match predicate { 132 | Matches => self.matches(jsonpath_res).assert(log_settings), 133 | DoesNotMatch => self.does_not_match(jsonpath_res).assert(log_settings), 134 | _ => unimplemented!("[TEST] Invalid predicate for the json path DSL: {predicate}"), 135 | } 136 | } 137 | } 138 | 139 | impl JsonPathDsl for RegexWrapper<&str> { 140 | fn eval( 141 | &self, 142 | jsonpath_res: JsonPathResult<'_, Value>, 143 | predicate: Predicate, 144 | log_settings: &LogSettings, 145 | ) -> Assertion { 146 | match predicate { 147 | Matches => self.matches(jsonpath_res).assert(log_settings), 148 | DoesNotMatch => self.does_not_match(jsonpath_res).assert(log_settings), 149 | _ => unimplemented!("Invalid predicate for the json path DSL: {predicate}"), 150 | } 151 | } 152 | } 153 | 154 | /// Http json body DSL to assert body of a response. 155 | pub trait JsonPathValueDsl: JsonPathDsl { 156 | /// Asserts that the json path value is strictly equals to the provided value. 157 | fn is(&self, jsonpath_res: JsonPathResult<'_, T>) -> Assertion; 158 | /// Asserts that the json path value is strictly not equals to the provided value. 159 | fn is_not(&self, jsonpath_res: JsonPathResult<'_, T>) -> Assertion; 160 | /// Asserts that the value of the json path matches the json schema. 161 | fn schema(&self, jsonpath_res: JsonPathResult<'_, T>) -> Assertion; 162 | /// Asserts that the json path value contains the provided value. 163 | fn contains(&self, jsonpath_res: JsonPathResult<'_, T>) -> Assertion; 164 | /// Asserts that the json path value does not contain the provided value. 165 | fn does_not_contain(&self, jsonpath_res: JsonPathResult<'_, T>) -> Assertion; 166 | } 167 | 168 | /// Http json path regex DSL. 169 | pub trait JsonPathRegexDsl: JsonPathDsl { 170 | /// Asserts that the json path value matches the regex. 171 | fn matches(&self, jsonpath_res: JsonPathResult<'_, T>) -> Assertion; 172 | /// Asserts that the json path value does not match the regex. 173 | fn does_not_match(&self, jsonpath_res: JsonPathResult<'_, T>) -> Assertion; 174 | /// Evaluates the json body assertion to run based on the [`Predicate`]. 175 | fn eval( 176 | &self, 177 | jsonpath_res: JsonPathResult<'_, T>, 178 | predicate: Predicate, 179 | log_settings: &LogSettings, 180 | ) -> Assertion { 181 | match predicate { 182 | Matches => self.matches(jsonpath_res).assert(log_settings), 183 | DoesNotMatch => self.does_not_match(jsonpath_res).assert(log_settings), 184 | _ => unimplemented!("[TEST] Invalid predicate for the json path DSL: {predicate}"), 185 | } 186 | } 187 | } 188 | 189 | impl JsonPathValueDsl for Value { 190 | fn is(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 191 | jsonpath_res.is_eq(self) 192 | } 193 | 194 | fn is_not(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 195 | jsonpath_res.is_ne(self) 196 | } 197 | 198 | fn schema(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 199 | jsonpath_res.matches_schema(self) 200 | } 201 | 202 | fn contains(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 203 | jsonpath_res.has(self) 204 | } 205 | 206 | fn does_not_contain(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 207 | jsonpath_res.has_not(self) 208 | } 209 | } 210 | 211 | impl JsonPathValueDsl for String { 212 | fn is(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 213 | jsonpath_res.is_eq(self) 214 | } 215 | 216 | fn is_not(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 217 | jsonpath_res.is_ne(self) 218 | } 219 | 220 | fn schema(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 221 | jsonpath_res.matches_schema(self) 222 | } 223 | 224 | fn contains(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 225 | jsonpath_res.has(self) 226 | } 227 | 228 | fn does_not_contain(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 229 | jsonpath_res.has_not(self) 230 | } 231 | } 232 | 233 | impl JsonPathValueDsl for &str { 234 | fn is(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 235 | jsonpath_res.is_eq(*self) 236 | } 237 | 238 | fn is_not(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 239 | jsonpath_res.is_ne(*self) 240 | } 241 | 242 | fn schema(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 243 | jsonpath_res.matches_schema(*self) 244 | } 245 | 246 | fn contains(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 247 | jsonpath_res.has(*self) 248 | } 249 | 250 | fn does_not_contain(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 251 | jsonpath_res.has_not(*self) 252 | } 253 | } 254 | 255 | impl JsonPathValueDsl for PathBuf { 256 | fn is(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 257 | jsonpath_res.is_eq(self) 258 | } 259 | 260 | fn is_not(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 261 | jsonpath_res.is_ne(self) 262 | } 263 | 264 | fn schema(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 265 | jsonpath_res.matches_schema(self) 266 | } 267 | 268 | fn contains(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 269 | jsonpath_res.has(self) 270 | } 271 | 272 | fn does_not_contain(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 273 | jsonpath_res.has_not(self) 274 | } 275 | } 276 | 277 | impl JsonPathRegexDsl for RegexWrapper { 278 | fn matches(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 279 | jsonpath_res.is_match(&self.0) 280 | } 281 | 282 | fn does_not_match(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 283 | jsonpath_res.is_not_match(&self.0) 284 | } 285 | } 286 | 287 | impl JsonPathRegexDsl for RegexWrapper<&str> { 288 | fn matches(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 289 | jsonpath_res.is_match(self.0) 290 | } 291 | 292 | fn does_not_match(&self, jsonpath_res: JsonPathResult<'_, Value>) -> Assertion { 293 | jsonpath_res.is_not_match(self.0) 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/dsl/mod.rs: -------------------------------------------------------------------------------- 1 | //! A domain-specific language organized into various modules providing built-in 2 | //! types and functions for performing declarative assertions. 3 | 4 | mod expression; 5 | #[allow(clippy::wrong_self_convention)] 6 | pub mod http; 7 | pub mod json_path; 8 | mod part; 9 | 10 | pub use self::expression::*; 11 | pub use self::part::Part; 12 | -------------------------------------------------------------------------------- /src/dsl/part.rs: -------------------------------------------------------------------------------- 1 | //! Module containing all the different parts we can assert against. These parts 2 | //! are also used to build assertion messages in a convenient way. 3 | 4 | use serde::{Deserialize, Serialize}; 5 | use strum::Display; 6 | 7 | /// Represents all the parts we can assert against. Provides a string 8 | /// representation for each variant to build assertion messages in a convenient 9 | /// way. 10 | #[derive(Display, Serialize, Deserialize, PartialEq, Eq, Debug)] 11 | pub enum Part { 12 | /// The json body of an http response. 13 | #[strum(serialize = "json body")] 14 | #[serde(rename = "json body")] 15 | JsonBody, 16 | /// The json value of an http response at the given path. 17 | #[strum(serialize = "json path")] 18 | #[serde(rename = "json path")] 19 | JsonPath, 20 | /// The headers in an http response. 21 | #[strum(serialize = "headers")] 22 | #[serde(rename = "headers")] 23 | Headers, 24 | /// A header in an http response. 25 | #[strum(serialize = "header")] 26 | #[serde(rename = "header")] 27 | Header, 28 | /// The status code of an http response. 29 | #[strum(serialize = "status code")] 30 | #[serde(rename = "status code")] 31 | StatusCode, 32 | /// The response time of an http response. 33 | #[strum(serialize = "response time")] 34 | #[serde(rename = "response time")] 35 | ResponseTime, 36 | /// The absence of part to assert from an http response. 37 | /// Usually used for an unprocessable assertion. 38 | #[strum(serialize = "none")] 39 | #[serde(rename = "none")] 40 | NoPart, 41 | } 42 | 43 | #[cfg(test)] 44 | pub mod tests { 45 | use super::Part; 46 | use serde_json::Value; 47 | use test_case::test_case; 48 | 49 | #[test_case(Value::String(String::from("json body")), Part::JsonBody; "Failed to deserialize part JsonBody")] 50 | #[test_case(Value::String(String::from("headers")), Part::Headers; "Failed to deserialize part Headers")] 51 | #[test_case(Value::String(String::from("header")), Part::Header; "Failed to deserialize part Header")] 52 | #[test_case(Value::String(String::from("status code")), Part::StatusCode; "Failed to deserialize part StatusCode")] 53 | #[test_case(Value::String(String::from("response time")), Part::ResponseTime; "Failed to deserialize part ResponseTime")] 54 | #[test_case(Value::String(String::from("json path")), Part::JsonPath; "Failed to deserialize part JsonPath")] 55 | fn deser_part(json_part: Value, part: Part) { 56 | assert_eq!(serde_json::from_value::(json_part).unwrap(), part) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | /// Short hand for `Result` type. 2 | pub type Result = std::result::Result; 3 | 4 | /// Represents the library errors. 5 | #[derive(thiserror::Error, Debug)] 6 | pub enum Error { 7 | /// Url parse error. 8 | #[error("Invalid URL")] 9 | UrlParseError(#[from] url::ParseError), 10 | /// Http client error. 11 | #[error("Http client error")] 12 | HttpClientError(#[from] reqwest::Error), 13 | /// Invalid header name. 14 | #[error("Invalid header name")] 15 | InvalidHeaderName(#[from] reqwest::header::InvalidHeaderName), 16 | /// Invalid header value. 17 | #[error("Invalid header value")] 18 | InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), 19 | } 20 | -------------------------------------------------------------------------------- /src/grillon.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::Request; 3 | use http::{HeaderMap, Method}; 4 | use reqwest::{Client, ClientBuilder}; 5 | use url::Url; 6 | 7 | /// Top-level instance to configure a REST API http client. 8 | /// 9 | /// [`Grillon`] provides everything to configure a REST API http client, 10 | /// and initiate a [`Request`]. 11 | pub struct Grillon { 12 | base_url: Url, 13 | client: Client, 14 | log_settings: LogSettings, 15 | } 16 | 17 | /// The log settings to output test results. 18 | /// 19 | /// The default configuration is `StdAssert`. 20 | #[derive(Clone)] 21 | pub enum LogSettings { 22 | /// Only prints assertion failures through `std::assert` macro. 23 | StdAssert, 24 | /// Prints all assertion results to the standard output. 25 | StdOutput, 26 | /// Formats assertion results into a json output. 27 | JsonOutput, 28 | } 29 | 30 | impl Default for LogSettings { 31 | fn default() -> Self { 32 | Self::StdAssert 33 | } 34 | } 35 | 36 | impl Grillon { 37 | /// Creates a new instance of `Grillon` with the base API url. 38 | /// 39 | /// # Example 40 | /// 41 | /// ```rust 42 | /// # use grillon::{Grillon, Result}; 43 | /// # fn run() -> Result<()> { 44 | /// let grillon = Grillon::new("https://jsonplaceholder.typicode.com")?; 45 | /// # Ok(()) 46 | /// # } 47 | /// ``` 48 | /// 49 | /// # Errors 50 | /// 51 | /// This function fails if the supplied base url cannot be parsed as a [`Url`]. 52 | pub fn new(base_url: &str) -> Result { 53 | let client = ClientBuilder::new().cookie_store(false).build()?; 54 | 55 | Ok(Grillon { 56 | base_url: base_url.parse::()?, 57 | client, 58 | log_settings: LogSettings::default(), 59 | }) 60 | } 61 | 62 | /// Configure the logs to print the test results. By default the 63 | /// [`LogSettings`] are configured to output with the test library 64 | /// assertions on the standard output with [`LogSettings::StdAssert`]. 65 | /// Only test failures will be printed. 66 | pub fn log_settings(mut self, log_settings: LogSettings) -> Self { 67 | self.log_settings = log_settings; 68 | 69 | self 70 | } 71 | 72 | /// Enable a persistent cookie store for the client. By default, 73 | /// no cookie store is used. Enabling the cookie store with `store_cookies()` 74 | /// will update the http client and set the store to a default implementation. 75 | pub fn store_cookies(mut self, enable: bool) -> Result { 76 | let client = ClientBuilder::new().cookie_store(enable).build()?; 77 | self.client = client; 78 | 79 | Ok(self) 80 | } 81 | 82 | /// Creates a new [`Request`] initialized with a `GET` method and the given path. 83 | /// 84 | /// # Example 85 | /// 86 | /// ```rust 87 | /// # use grillon::{Grillon, Result}; 88 | /// # fn run() -> Result<()> { 89 | /// let request = Grillon::new("https://jsonplaceholder.typicode.com")? 90 | /// .get("users"); 91 | /// # Ok(()) 92 | /// # } 93 | /// ``` 94 | pub fn get(&self, path: &str) -> Request<'_> { 95 | self.http_request(Method::GET, path) 96 | } 97 | 98 | /// Creates a new [`Request`] initialized with a `POST` method and the given path. 99 | /// 100 | /// # Example 101 | /// 102 | /// ```rust 103 | /// # use grillon::{Grillon, Result}; 104 | /// # fn run() -> Result<()> { 105 | /// let request = Grillon::new("https://jsonplaceholder.typicode.com")? 106 | /// .post("users"); 107 | /// # Ok(()) 108 | /// # } 109 | /// ``` 110 | pub fn post(&self, path: &str) -> Request<'_> { 111 | self.http_request(Method::POST, path) 112 | } 113 | 114 | /// Creates a new [`Request`] initialized with a `PUT` method and the given path. 115 | /// 116 | /// # Example 117 | /// 118 | /// ```rust 119 | /// # use grillon::{Grillon, Result}; 120 | /// # fn run() -> Result<()> { 121 | /// let request = Grillon::new("https://jsonplaceholder.typicode.com")? 122 | /// .put("users/1"); 123 | /// # Ok(()) 124 | /// # } 125 | /// ``` 126 | pub fn put(&self, path: &str) -> Request<'_> { 127 | self.http_request(Method::PUT, path) 128 | } 129 | 130 | /// Creates a new [`Request`] initialized with a `PATCH` method and the given path. 131 | /// 132 | /// # Example 133 | /// 134 | /// ```rust 135 | /// # use grillon::{Grillon, Result}; 136 | /// # fn run() -> Result<()> { 137 | /// let request = Grillon::new("https://jsonplaceholder.typicode.com")? 138 | /// .patch("users/1"); 139 | /// # Ok(()) 140 | /// # } 141 | /// ``` 142 | pub fn patch(&self, path: &str) -> Request<'_> { 143 | self.http_request(Method::PATCH, path) 144 | } 145 | 146 | /// Creates a new [`Request`] initialized with a `DELETE` method and the given path. 147 | /// 148 | /// # Example 149 | /// 150 | /// ```rust 151 | /// # use grillon::{Grillon, Result}; 152 | /// # fn run() -> Result<()> { 153 | /// let request = Grillon::new("https://jsonplaceholder.typicode.com")? 154 | /// .delete("users/1"); 155 | /// # Ok(()) 156 | /// # } 157 | /// ``` 158 | pub fn delete(&self, path: &str) -> Request<'_> { 159 | self.http_request(Method::DELETE, path) 160 | } 161 | 162 | /// Creates a new [`Request`] initialized with an `OPTIONS` method and the given path. 163 | /// 164 | /// # Example 165 | /// 166 | /// ```rust 167 | /// # use grillon::{Grillon, Result, dsl::contains, header::{ACCESS_CONTROL_ALLOW_METHODS, HeaderValue}}; 168 | /// # async fn run() -> Result<()> { 169 | /// Grillon::new("https://jsonplaceholder.typicode.com")? 170 | /// .options("") 171 | /// .assert() 172 | /// .await 173 | /// .headers(contains(vec![( 174 | /// ACCESS_CONTROL_ALLOW_METHODS, 175 | /// HeaderValue::from_static("GET,HEAD,PUT,PATCH,POST,DELETE"), 176 | /// )])); 177 | /// # Ok(()) 178 | /// # } 179 | /// ``` 180 | pub fn options(&self, path: &str) -> Request<'_> { 181 | self.http_request(Method::OPTIONS, path) 182 | } 183 | 184 | /// Creates a new [`Request`] initialized with an `HEAD` method and the given path. 185 | /// 186 | /// # Example 187 | /// 188 | /// ```rust 189 | /// # use grillon::{Grillon, Result, dsl::contains, header::{CONTENT_LENGTH, HeaderValue}}; 190 | /// # async fn run() -> Result<()> { 191 | /// Grillon::new("https://jsonplaceholder.typicode.com")? 192 | /// .head("photos/1") 193 | /// .assert() 194 | /// .await 195 | /// .headers(contains(vec![(CONTENT_LENGTH, HeaderValue::from_static("205"))])); 196 | /// # Ok(()) 197 | /// # } 198 | /// ``` 199 | pub fn head(&self, path: &str) -> Request<'_> { 200 | self.http_request(Method::HEAD, path) 201 | } 202 | 203 | /// Creates a new [`Request`] initialized with a `CONNECT` method and the given path. 204 | /// 205 | /// # Example 206 | /// 207 | /// ```rust 208 | /// # use grillon::{Grillon, Result}; 209 | /// # fn run() -> Result<()> { 210 | /// let request = Grillon::new("http://home.netscape.com")? 211 | /// .connect(""); 212 | /// # Ok(()) 213 | /// # } 214 | /// ``` 215 | pub fn connect(&self, path: &str) -> Request<'_> { 216 | self.http_request(Method::CONNECT, path) 217 | } 218 | 219 | /// Create a new [`Request`] initialized with the given method and path. 220 | /// 221 | /// # Example 222 | /// 223 | /// ```rust 224 | /// # use grillon::{Grillon, Method, Result}; 225 | /// # fn run() -> Result<()> { 226 | /// let request = Grillon::new("https://jsonplaceholder.typicode.com")? 227 | /// .http_request(Method::GET, "users"); 228 | /// # Ok(()) 229 | /// # } 230 | /// ``` 231 | pub fn http_request(&self, method: Method, path: &str) -> Request<'_> { 232 | let url = crate::url::concat(&self.base_url, path).unwrap_or_else(|err| panic!("{}", err)); 233 | 234 | Request { 235 | method, 236 | url, 237 | headers: Ok(HeaderMap::new()), 238 | payload: None, 239 | client: &self.client, 240 | log_settings: &self.log_settings, 241 | basic_auth: None, 242 | bearer_auth: None, 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny( 2 | rust_2018_idioms, 3 | nonstandard_style, 4 | macro_use_extern_crate, 5 | rustdoc::broken_intra_doc_links, 6 | rustdoc::private_intra_doc_links, 7 | trivial_casts, 8 | trivial_numeric_casts 9 | )] 10 | #![warn(missing_docs)] 11 | #![forbid(non_ascii_idents, unsafe_code)] 12 | #![doc = include_str!("../README.md")] 13 | 14 | pub mod assert; 15 | pub mod assertion; 16 | pub mod dsl; 17 | mod error; 18 | mod grillon; 19 | pub mod request; 20 | pub mod response; 21 | mod url; 22 | 23 | #[doc(inline)] 24 | pub use self::{ 25 | assert::Assert, 26 | error::{Error, Result}, 27 | grillon::{Grillon, LogSettings}, 28 | request::Request, 29 | response::Response, 30 | }; 31 | 32 | pub use http::{header, Method, StatusCode}; 33 | pub use serde_json::{json, Value}; 34 | -------------------------------------------------------------------------------- /src/request.rs: -------------------------------------------------------------------------------- 1 | //! The `request` module provides everything to build http requests 2 | //! for endpoints under tests. 3 | //! 4 | //! Currently powered by the [`Reqwest`](https://github.com/seanmonstar/reqwest) HTTP client. 5 | use std::fmt::Display; 6 | use std::{str::FromStr, time::Instant}; 7 | 8 | use crate::assertion::{Assertion, AssertionResult, Hand, UnprocessableReason}; 9 | use crate::dsl::{Part, Predicate}; 10 | use crate::error::Result; 11 | use crate::{assert::Assert, grillon::LogSettings}; 12 | use http::{HeaderMap, HeaderName, HeaderValue, Method}; 13 | use reqwest::{Body, Client}; 14 | use serde_json::Value; 15 | use url::Url; 16 | 17 | /// List of methods where there is no associated body. 18 | const METHODS_NO_BODY: &[Method] = &[ 19 | Method::CONNECT, 20 | Method::HEAD, 21 | Method::GET, 22 | Method::OPTIONS, 23 | Method::TRACE, 24 | ]; 25 | 26 | /// Represents the basic authentication information for a [`Request`]. 27 | /// 28 | /// [`Request`]: crate::Request 29 | pub struct BasicAuth { 30 | username: String, 31 | password: Option, 32 | } 33 | 34 | /// Represents the bearer authentication information for a [`Request`]. 35 | /// 36 | /// [`Request`]: crate::Request 37 | pub struct BearerToken(String); 38 | 39 | /// A generic http request headers representation. 40 | /// 41 | /// [`Grillon`] allows the use of different types 42 | /// to represent headers that are convertible to an [`HeaderMap`]. 43 | /// 44 | /// [`Grillon`]: crate::Grillon 45 | pub trait RequestHeaders { 46 | /// Converts http request headers to an [`HeaderMap`]. 47 | /// Any type implementing this trait's function can be 48 | /// passed in [`Request::headers()`]. 49 | fn to_header_map(&self) -> Result; 50 | } 51 | 52 | impl RequestHeaders for Vec<(HeaderName, HeaderValue)> { 53 | fn to_header_map(&self) -> Result { 54 | let mut map = HeaderMap::new(); 55 | 56 | for (key, value) in self { 57 | map.append(key, value.clone()); 58 | } 59 | 60 | Ok(map) 61 | } 62 | } 63 | 64 | impl RequestHeaders for Vec<(&str, &str)> { 65 | fn to_header_map(&self) -> Result { 66 | let mut map = HeaderMap::new(); 67 | 68 | for (key, value) in self { 69 | map.append(HeaderName::from_str(key)?, HeaderValue::from_str(value)?); 70 | } 71 | 72 | Ok(map) 73 | } 74 | } 75 | 76 | impl RequestHeaders for HeaderMap { 77 | fn to_header_map(&self) -> Result { 78 | Ok(self.clone()) 79 | } 80 | } 81 | 82 | /// Represents an outgoing http request. 83 | /// 84 | /// Can be executed with [`Request::assert()`]. 85 | pub struct Request<'c> { 86 | /// The http request method. 87 | pub method: Method, 88 | /// The http request url. 89 | pub url: Url, 90 | /// The http request headers. 91 | pub headers: Result, 92 | /// The http request payload. 93 | pub payload: Option, 94 | /// The client used for this outgoing request. 95 | pub client: &'c Client, 96 | /// The log settings that will be used to output test results 97 | /// when asserting the http response. 98 | pub log_settings: &'c LogSettings, 99 | /// Basic authenthication information. 100 | pub basic_auth: Option, 101 | /// Bearer authentication token. 102 | pub bearer_auth: Option, 103 | } 104 | 105 | impl Request<'_> { 106 | /// Sets the headers to the [`Request`]. 107 | /// 108 | /// # Example 109 | /// 110 | /// ```rust 111 | /// # use grillon::{Grillon, Result, header}; 112 | /// # fn run() -> Result<()> { 113 | /// Grillon::new("https://jsonplaceholder.typicode.com")? 114 | /// .delete("users/1") 115 | /// .headers(vec![( 116 | /// header::CONTENT_TYPE, 117 | /// header::HeaderValue::from_static("application/json"), 118 | /// )]); 119 | /// # Ok(()) 120 | /// # } 121 | /// ``` 122 | pub fn headers(mut self, headers: H) -> Self { 123 | self.headers = headers.to_header_map(); 124 | 125 | self 126 | } 127 | 128 | /// Sets the body to the [`Request`]. 129 | /// 130 | /// # Example 131 | /// 132 | /// ```rust 133 | /// # use grillon::{Grillon, Result, header, json}; 134 | /// # fn run() -> Result<()> { 135 | /// Grillon::new("https://jsonplaceholder.typicode.com")? 136 | /// .post("users") 137 | /// .payload(json!({ 138 | /// "name": "Isaac", 139 | /// })); 140 | /// # Ok(()) 141 | /// # } 142 | /// ``` 143 | pub fn payload(mut self, json: Value) -> Self { 144 | // TODO: See to manage this as an error to collect. To avoid confusion 145 | // for users we warn them without failing since it might be intended. 146 | // We can maybe find a better way to manage this case. 147 | if METHODS_NO_BODY.contains(&self.method) { 148 | println!( 149 | "{} does not support HTTP body. No payload will be sent.", 150 | self.method 151 | ); 152 | 153 | return self; 154 | } 155 | 156 | self.payload = Some(Body::from(json.to_string())); 157 | 158 | self 159 | } 160 | 161 | /// Enable HTTP basic authentication. 162 | /// 163 | /// Basic authentication will automatically be considered as a sensitive 164 | /// header. 165 | pub fn basic_auth(mut self, username: U, password: Option

) -> Self 166 | where 167 | U: AsRef + Display, 168 | P: AsRef + Display, 169 | { 170 | self.basic_auth = Some(BasicAuth { 171 | username: username.to_string(), 172 | password: password.map(|pwd| pwd.to_string()), 173 | }); 174 | 175 | self 176 | } 177 | 178 | /// Enable HTTP bearer authentication. 179 | /// 180 | /// Bearer authentication will automatically be considered as a sensitive 181 | /// header. 182 | pub fn bearer_auth(mut self, token: T) -> Self 183 | where 184 | T: AsRef + Display, 185 | { 186 | self.bearer_auth = Some(BearerToken(token.to_string())); 187 | self 188 | } 189 | 190 | /// Sends the http request and creates an instance of [`Assert`] with the http response. 191 | /// 192 | /// This function consumes the [`Request`]. 193 | /// 194 | /// # Example 195 | /// 196 | /// ```rust 197 | /// # use grillon::{Grillon, Result}; 198 | /// # async fn run() -> Result<()> { 199 | /// Grillon::new("https://jsonplaceholder.typicode.com")? 200 | /// .get("users") 201 | /// .assert() 202 | /// .await; 203 | /// # Ok(()) 204 | /// # } 205 | /// ``` 206 | pub async fn assert(self) -> Assert { 207 | let headers = match self.headers { 208 | Ok(headers) => headers, 209 | Err(err) => { 210 | let assertion = Assertion { 211 | part: Part::Headers, 212 | predicate: Predicate::NoPredicate, 213 | left: Hand::Empty::, 214 | right: Hand::Empty, 215 | result: AssertionResult::Unprocessable( 216 | UnprocessableReason::InvalidHttpRequestHeaders(err.to_string()), 217 | ), 218 | }; 219 | 220 | assertion.assert(self.log_settings); 221 | 222 | return Assert::new(None::, None, self.log_settings.clone()) 223 | .await; 224 | } 225 | }; 226 | 227 | let mut req = self 228 | .client 229 | .request(self.method, self.url) 230 | .body(self.payload.unwrap_or_default()) 231 | .headers(headers); 232 | 233 | // Check for auth settings 234 | if let Some(basic_auth) = self.basic_auth { 235 | let BasicAuth { username, password } = basic_auth; 236 | req = req.basic_auth(username, password); 237 | } 238 | 239 | if let Some(bearer_auth) = self.bearer_auth { 240 | let BearerToken(token) = bearer_auth; 241 | req = req.bearer_auth(token); 242 | } 243 | 244 | let now = Instant::now(); 245 | let response = match req.send().await { 246 | Ok(response) => response, 247 | Err(err) => { 248 | let assertion = Assertion { 249 | part: Part::NoPart, 250 | predicate: Predicate::NoPredicate, 251 | left: Hand::Empty::, 252 | right: Hand::Empty, 253 | result: AssertionResult::Unprocessable( 254 | UnprocessableReason::HttpRequestFailure(err.to_string()), 255 | ), 256 | }; 257 | 258 | assertion.assert(self.log_settings); 259 | 260 | return Assert::new(None::, None, self.log_settings.clone()) 261 | .await; 262 | } 263 | }; 264 | 265 | // Due to serde limitations with 128bits we need to cast u128 to u64 266 | // with the risk to lose precision. However should be acceptable since 267 | // the api of Duration::from_millis() accepts a u64 value. 268 | // 269 | // See https://github.com/serde-rs/serde/issues/1717 270 | // See https://github.com/serde-rs/serde/issues/1183 271 | let response_time_ms = now.elapsed().as_millis() as u64; 272 | 273 | Assert::new( 274 | Some(response), 275 | Some(response_time_ms), 276 | self.log_settings.clone(), 277 | ) 278 | .await 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /src/response.rs: -------------------------------------------------------------------------------- 1 | //! The `response` module provides everything to implement custom responses that can 2 | //! be asserted with [`Assert`]. 3 | //! 4 | //! [`Grillon`] provides a default implementation of [`Response`] with [`Hyper`](https://github.com/hyperium/hyper). 5 | //! 6 | //! [`Assert`]: crate::Assert 7 | //! [`Grillon`]: crate::Grillon 8 | use futures::{future::LocalBoxFuture, FutureExt}; 9 | use http::{HeaderMap, StatusCode}; 10 | use reqwest::Response as ReqwestResponse; 11 | use serde_json::Value; 12 | 13 | /// A generic http response representation with 14 | /// convenience methods for subsequent assertions 15 | /// with [`Assert`]. 16 | /// 17 | /// [`Assert`]: crate::Assert 18 | pub trait Response { 19 | /// Returns the http status code. 20 | fn status(&self) -> StatusCode; 21 | /// Returns a future with the response json body. 22 | fn json<'a>(self) -> LocalBoxFuture<'a, Option>; 23 | /// Returns the response headers. 24 | fn headers(&self) -> HeaderMap; 25 | } 26 | 27 | impl Response for ReqwestResponse { 28 | fn status(&self) -> StatusCode { 29 | self.status() 30 | } 31 | 32 | fn json<'a>(self) -> LocalBoxFuture<'a, Option> { 33 | async move { 34 | if let Ok(bytes) = self.bytes().await { 35 | if bytes.is_empty() { 36 | return None; 37 | } 38 | let json: Value = serde_json::from_slice(&bytes).expect("Failed to decode json"); 39 | 40 | Some(json) 41 | } else { 42 | None 43 | } 44 | } 45 | .boxed_local() 46 | } 47 | 48 | fn headers(&self) -> HeaderMap { 49 | self.headers().clone() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/url.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use url::Url; 3 | 4 | pub(crate) fn concat(base: &Url, path: &str) -> Result { 5 | format!("{}{}", base, path) 6 | .parse::() 7 | .map_err(|err| err.into()) 8 | } 9 | -------------------------------------------------------------------------------- /tests/assert/assert_fn.rs: -------------------------------------------------------------------------------- 1 | use crate::HttpMockServer; 2 | use grillon::{dsl::http::is_success, Assert, Grillon, Result, StatusCode}; 3 | 4 | #[tokio::test] 5 | async fn custom_assert() -> Result<()> { 6 | let mock_server = HttpMockServer::new(); 7 | let mock = mock_server.get_valid_user(); 8 | 9 | Grillon::new(mock_server.server.url("/").as_ref())? 10 | .get("users/1") 11 | .assert() 12 | .await 13 | .assert_fn(|assert| { 14 | let Assert { 15 | headers, 16 | status, 17 | json, 18 | .. 19 | } = assert.clone(); 20 | 21 | if let Some(headers) = headers { 22 | assert!(!headers.is_empty()); 23 | } 24 | 25 | assert!(status == Some(StatusCode::OK)); 26 | 27 | if let Some(json) = json { 28 | assert!(json.is_some()); 29 | println!("Json response : {:#?}", json); 30 | } 31 | }) 32 | .status(is_success()); 33 | 34 | mock.assert(); 35 | 36 | Ok(()) 37 | } 38 | -------------------------------------------------------------------------------- /tests/assert/auth.rs: -------------------------------------------------------------------------------- 1 | use crate::HttpMockServer; 2 | use grillon::dsl::http::is_success; 3 | use grillon::{Grillon, Result}; 4 | 5 | #[tokio::test] 6 | async fn it_should_set_bearer_auth_header() -> Result<()> { 7 | let mock_server = HttpMockServer::new(); 8 | let bearer_auth = mock_server.bearer_auth(); 9 | 10 | Grillon::new(&mock_server.server.url("/"))? 11 | .get("auth/bearer/endpoint") 12 | .bearer_auth("token-123") 13 | .assert() 14 | .await 15 | .status(is_success()); 16 | 17 | bearer_auth.assert(); 18 | 19 | Ok(()) 20 | } 21 | 22 | #[tokio::test] 23 | async fn it_should_set_basic_auth_header() -> Result<()> { 24 | let mock_server = HttpMockServer::new(); 25 | let basic_auth = mock_server.basic_auth(); 26 | 27 | Grillon::new(&mock_server.server.url("/"))? 28 | .get("auth/basic/endpoint") 29 | .basic_auth("isaac", Some("rayne")) 30 | .assert() 31 | .await 32 | .status(is_success()); 33 | 34 | basic_auth.assert(); 35 | 36 | Ok(()) 37 | } 38 | -------------------------------------------------------------------------------- /tests/assert/cookies.rs: -------------------------------------------------------------------------------- 1 | use crate::HttpMockServer; 2 | use grillon::dsl::{ 3 | contains, 4 | http::{is_client_error, is_success}, 5 | }; 6 | use grillon::{ 7 | header::{HeaderValue, SET_COOKIE}, 8 | Grillon, Result, 9 | }; 10 | 11 | #[tokio::test] 12 | async fn cookies_should_be_stored() -> Result<()> { 13 | let mock_server = HttpMockServer::new(); 14 | let auth_mock = mock_server.session_auth(); 15 | let auth_endpoint_mock = mock_server.session_based_request(); 16 | 17 | let grillon = Grillon::new(&mock_server.server.url("/"))?.store_cookies(true)?; 18 | 19 | grillon 20 | .post("auth/session") 21 | .assert() 22 | .await 23 | .headers(contains(vec![( 24 | SET_COOKIE, 25 | HeaderValue::from_static("SESSIONID=123; HttpOnly"), 26 | )])); 27 | 28 | grillon 29 | .get("auth/session/endpoint") 30 | .assert() 31 | .await 32 | .status(is_success()); 33 | 34 | auth_mock.assert(); 35 | auth_endpoint_mock.assert(); 36 | 37 | Ok(()) 38 | } 39 | 40 | #[tokio::test] 41 | #[should_panic] 42 | async fn disabled_cookie_store_should_not_send_cookies() { 43 | let mock_server = HttpMockServer::new(); 44 | let auth_mock = mock_server.session_auth(); 45 | let auth_endpoint_mock = mock_server.session_based_request(); 46 | 47 | let grillon = Grillon::new(&mock_server.server.url("/")).unwrap(); 48 | 49 | grillon 50 | .post("auth/session") 51 | .assert() 52 | .await 53 | .headers(contains(vec![( 54 | SET_COOKIE, 55 | HeaderValue::from_static("SESSIONID=123; HttpOnly"), 56 | )])); 57 | 58 | grillon 59 | .store_cookies(false) 60 | .unwrap() 61 | .get("auth/session/endpoint") 62 | .assert() 63 | .await 64 | .status(is_client_error()); 65 | 66 | auth_mock.assert(); 67 | // This should panic because the SESSIONID cookie isn't set. 68 | auth_endpoint_mock.assert(); 69 | } 70 | 71 | #[tokio::test] 72 | #[should_panic] 73 | async fn missing_cookies_should_panic() { 74 | let mock_server = HttpMockServer::new(); 75 | let auth_mock = mock_server.session_auth(); 76 | let auth_endpoint_mock = mock_server.session_based_request(); 77 | 78 | let grillon = Grillon::new(&mock_server.server.url("/")).unwrap(); 79 | 80 | grillon 81 | .post("auth/session") 82 | .assert() 83 | .await 84 | .headers(contains(vec![( 85 | SET_COOKIE, 86 | HeaderValue::from_static("SESSIONID=123; HttpOnly"), 87 | )])); 88 | 89 | grillon 90 | .get("auth/session/endpoint") 91 | .assert() 92 | .await 93 | .status(is_client_error()); 94 | 95 | auth_mock.assert(); 96 | // This should panic because the SESSIONID cookie isn't set. 97 | auth_endpoint_mock.assert(); 98 | } 99 | -------------------------------------------------------------------------------- /tests/assert/headers.rs: -------------------------------------------------------------------------------- 1 | use crate::HttpMockServer; 2 | use grillon::{ 3 | dsl::{contains, does_not_contain, is, is_not}, 4 | header::{HeaderMap, HeaderValue, CONTENT_LENGTH, CONTENT_TYPE, DATE}, 5 | Grillon, Method, Result, 6 | }; 7 | 8 | #[tokio::test] 9 | async fn headers_equality() -> Result<()> { 10 | let mock_server = HttpMockServer::new(); 11 | let mock = mock_server.get_valid_user(); 12 | let mut header_map = HeaderMap::new(); 13 | let mut header_vec = Vec::new(); 14 | let (content_type, content_length, date) = ( 15 | HeaderValue::from_static("application/json"), 16 | HeaderValue::from_static("23"), 17 | HeaderValue::from_static("today"), 18 | ); 19 | 20 | header_map.insert(CONTENT_TYPE, content_type.clone()); 21 | header_map.insert(CONTENT_LENGTH, content_length.clone()); 22 | header_map.insert(DATE, date.clone()); 23 | header_vec.push((CONTENT_TYPE, content_type)); 24 | header_vec.push((CONTENT_LENGTH, content_length.clone())); 25 | header_vec.push((DATE, date)); 26 | 27 | Grillon::new(mock_server.server.url("/").as_ref())? 28 | .get("users/1") 29 | .assert() 30 | .await 31 | .headers(is(header_map)) 32 | .headers(is(header_vec)) 33 | .headers(is(vec![ 34 | ("content-type", "application/json"), 35 | ("content-length", "23"), 36 | ("date", "today"), 37 | ])) 38 | .headers(is_not(vec![(CONTENT_LENGTH, content_length)])) 39 | .headers(is_not(vec![("content-length", "23")])); 40 | 41 | mock.assert(); 42 | 43 | Ok(()) 44 | } 45 | 46 | #[tokio::test] 47 | async fn headers_contains() -> Result<()> { 48 | let mock_server = HttpMockServer::new(); 49 | let mock = mock_server.get_valid_user(); 50 | let vec_header_map = vec![(CONTENT_TYPE, HeaderValue::from_static("application/json"))]; 51 | let mut header_map = HeaderMap::new(); 52 | header_map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); 53 | 54 | Grillon::new(mock_server.server.url("/").as_ref())? 55 | .get("users/1") 56 | .assert() 57 | .await 58 | .headers(contains(vec_header_map)) 59 | .headers(contains(header_map)); 60 | 61 | mock.assert(); 62 | 63 | Ok(()) 64 | } 65 | 66 | #[tokio::test] 67 | async fn headers_absent() -> Result<()> { 68 | let mock_server = HttpMockServer::new(); 69 | let mock = mock_server.get_valid_user(); 70 | let vec_header_map = vec![(CONTENT_TYPE, HeaderValue::from_static("text/html"))]; 71 | let mut header_map = HeaderMap::new(); 72 | header_map.insert(CONTENT_TYPE, HeaderValue::from_static("text/html")); 73 | 74 | Grillon::new(mock_server.server.url("/").as_ref())? 75 | .get("users/1") 76 | .assert() 77 | .await 78 | .headers(does_not_contain(vec_header_map)) 79 | .headers(does_not_contain(header_map)) 80 | .headers(does_not_contain(vec![("content-type", "text-html")])); 81 | 82 | mock.assert(); 83 | 84 | Ok(()) 85 | } 86 | 87 | #[tokio::test] 88 | async fn headers_check_empty_against_not_empty() -> Result<()> { 89 | let mock_server = HttpMockServer::new(); 90 | let mock = mock_server.get_empty_response(); 91 | 92 | // The MockServer always returns the content type and the date in headers 93 | Grillon::new(&mock_server.server.url("/"))? 94 | .get("empty") 95 | .assert() 96 | .await 97 | .headers(contains(Vec::<(http::HeaderName, http::HeaderValue)>::new())); 98 | 99 | mock.assert(); 100 | 101 | Ok(()) 102 | } 103 | 104 | #[tokio::test] 105 | async fn invalid_request_headers() -> Result<()> { 106 | let mock_server = HttpMockServer::new(); 107 | let mock = mock_server.delete_valid_user(); 108 | 109 | let grillon = Grillon::new(mock_server.server.url("/users").as_ref())? 110 | .log_settings(grillon::LogSettings::StdOutput); 111 | 112 | grillon 113 | .http_request(Method::DELETE, "users/1") 114 | .headers(vec![("ééç", "header value")]) 115 | .assert() 116 | .await 117 | .headers(is(vec![("ééç", "header value")])) 118 | .status(is(204)); 119 | 120 | mock.assert_hits(0); 121 | 122 | Ok(()) 123 | } 124 | 125 | #[tokio::test] 126 | async fn single_header_equality() -> Result<()> { 127 | let mock_server = HttpMockServer::new(); 128 | let mock = mock_server.get_valid_user(); 129 | 130 | Grillon::new(mock_server.server.url("/").as_ref())? 131 | .get("users/1") 132 | .assert() 133 | .await 134 | .header("content-type", is("application/json")) 135 | .header( 136 | "content-type".to_string(), 137 | is("application/json".to_string()), 138 | ) 139 | .header( 140 | CONTENT_TYPE, 141 | is(HeaderValue::from_static("application/json")), 142 | ) 143 | .header("content-type", is_not("application/html")) 144 | .header( 145 | "content-type".to_string(), 146 | is_not("application/html".to_string()), 147 | ) 148 | .header( 149 | CONTENT_TYPE, 150 | is_not(HeaderValue::from_static("application/html")), 151 | ); 152 | 153 | mock.assert(); 154 | 155 | Ok(()) 156 | } 157 | -------------------------------------------------------------------------------- /tests/assert/json_body.rs: -------------------------------------------------------------------------------- 1 | use crate::HttpMockServer; 2 | use grillon::{ 3 | dsl::{is, is_not}, 4 | header::{HeaderValue, CONTENT_TYPE}, 5 | json, Grillon, Result, 6 | }; 7 | 8 | #[tokio::test] 9 | async fn json_body() -> Result<()> { 10 | let mock_server = HttpMockServer::new(); 11 | let mock = mock_server.get_valid_user(); 12 | let json_header_map = vec![(CONTENT_TYPE, HeaderValue::from_static("application/json"))]; 13 | 14 | Grillon::new(&mock_server.server.url("/"))? 15 | .get("users/1") 16 | .headers(json_header_map) 17 | .assert() 18 | .await 19 | .json_body(is(json!({ 20 | "id": 1, 21 | "name": "Isaac", 22 | }))); 23 | 24 | mock.assert(); 25 | 26 | Ok(()) 27 | } 28 | 29 | #[tokio::test] 30 | async fn raw_string_body() -> Result<()> { 31 | let mock_server = HttpMockServer::new(); 32 | let mock = mock_server.get_valid_user(); 33 | let json_header_map = vec![(CONTENT_TYPE, HeaderValue::from_static("application/json"))]; 34 | 35 | Grillon::new(&mock_server.server.url("/"))? 36 | .get("users/1") 37 | .headers(json_header_map) 38 | .assert() 39 | .await 40 | .json_body(is(r#" 41 | { 42 | "id": 1, 43 | "name": "Isaac" 44 | } 45 | "#)); 46 | 47 | mock.assert(); 48 | 49 | Ok(()) 50 | } 51 | 52 | #[tokio::test] 53 | async fn string_body() -> Result<()> { 54 | let mock_server = HttpMockServer::new(); 55 | let mock = mock_server.get_valid_user(); 56 | let json_header_map = vec![(CONTENT_TYPE, HeaderValue::from_static("application/json"))]; 57 | let json = r#" 58 | { 59 | "id": 1, 60 | "name": "Isaac" 61 | } 62 | "# 63 | .to_string(); 64 | 65 | Grillon::new(&mock_server.server.url("/"))? 66 | .get("users/1") 67 | .headers(json_header_map) 68 | .assert() 69 | .await 70 | .json_body(is(json)); 71 | 72 | mock.assert(); 73 | 74 | Ok(()) 75 | } 76 | 77 | #[tokio::test] 78 | async fn it_should_not_be_equals() { 79 | let mock_server = HttpMockServer::new(); 80 | mock_server.get_valid_user(); 81 | 82 | Grillon::new(&mock_server.server.url("/")) 83 | .unwrap() 84 | .get("users/1") 85 | .assert() 86 | .await 87 | .json_body(is_not(json!({ 88 | "id": 101, 89 | "name": "Ecbert", 90 | }))); 91 | } 92 | 93 | #[tokio::test] 94 | #[should_panic] 95 | async fn it_should_fail_to_compare_bad_body() { 96 | let mock_server = HttpMockServer::new(); 97 | mock_server.get_valid_user(); 98 | 99 | Grillon::new(&mock_server.server.url("/")) 100 | .unwrap() 101 | .get("users/1") 102 | .assert() 103 | .await 104 | .json_body(is(json!({ 105 | "id": 100, 106 | "name": "Tom", 107 | }))); 108 | } 109 | 110 | #[tokio::test] 111 | #[should_panic] 112 | async fn it_should_fail_to_compare_inexistant_body() { 113 | let mock_server = HttpMockServer::new(); 114 | mock_server.get_empty_response(); 115 | 116 | Grillon::new(&mock_server.server.url("/")) 117 | .unwrap() 118 | .get("empty") 119 | .assert() 120 | .await 121 | .json_body(is(json!({ 122 | "id": 1, 123 | "name": "Isaac", 124 | }))); 125 | } 126 | -------------------------------------------------------------------------------- /tests/assert/json_path.rs: -------------------------------------------------------------------------------- 1 | use crate::HttpMockServer; 2 | use grillon::{ 3 | dsl::{contains, does_not_contain, does_not_match, is, is_not, matches}, 4 | json, Grillon, Result, 5 | }; 6 | 7 | #[tokio::test] 8 | async fn json_path_should_be_equal_to_json() -> Result<()> { 9 | let mock_server = HttpMockServer::new(); 10 | let mock = mock_server.get_valid_user(); 11 | 12 | Grillon::new(&mock_server.server.url("/"))? 13 | .get("users/1") 14 | .assert() 15 | .await 16 | .json_path("$.id", is(json!(1))) 17 | .json_path("$.id", is("1")); 18 | 19 | mock.assert(); 20 | 21 | Ok(()) 22 | } 23 | 24 | #[tokio::test] 25 | #[should_panic] 26 | async fn json_path_should_be_equal_failure_bad_data() { 27 | let mock_server = HttpMockServer::new(); 28 | mock_server.get_valid_user(); 29 | 30 | let expected_json = json!({ 31 | "id": 1, 32 | "name": "Max", 33 | }); 34 | 35 | Grillon::new(&mock_server.server.url("/")) 36 | .unwrap() 37 | .get("users/1") 38 | .assert() 39 | .await 40 | .json_path("$", is(expected_json)); 41 | } 42 | 43 | #[tokio::test] 44 | #[should_panic] 45 | async fn json_path_should_be_equal_failure_no_data() { 46 | let mock_server = HttpMockServer::new(); 47 | mock_server.get_valid_user(); 48 | 49 | let expected_json = json!({ 50 | "id": 1, 51 | "name": "Isaac", 52 | }); 53 | 54 | Grillon::new(&mock_server.server.url("/")) 55 | .unwrap() 56 | .get("users/1") 57 | .assert() 58 | .await 59 | .json_path("$.lastname", is(expected_json)); 60 | } 61 | 62 | #[tokio::test] 63 | async fn json_path_should_not_be_equal() -> Result<()> { 64 | let mock_server = HttpMockServer::new(); 65 | let mock = mock_server.get_valid_user(); 66 | 67 | let json = json!({ 68 | "id": 2, 69 | "name": "Max", 70 | }); 71 | 72 | Grillon::new(&mock_server.server.url("/"))? 73 | .get("users/1") 74 | .assert() 75 | .await 76 | .json_path("$", is_not(json)); 77 | 78 | mock.assert(); 79 | 80 | Ok(()) 81 | } 82 | 83 | #[tokio::test] 84 | async fn json_path_contains() -> Result<()> { 85 | let mock_server = HttpMockServer::new(); 86 | let mock = mock_server.get_valid_user(); 87 | 88 | let json = json!({ 89 | "id": 1, 90 | "name": "Isaac", 91 | }); 92 | let raw_json = r#"{ 93 | "id": 1, 94 | "name": "Isaac" 95 | }"#; 96 | 97 | Grillon::new(&mock_server.server.url("/"))? 98 | .get("users/1") 99 | .assert() 100 | .await 101 | .json_path("$", contains(json)) 102 | .json_path("$", contains(raw_json)) 103 | .json_path("$.id", contains("1")); 104 | 105 | mock.assert(); 106 | 107 | Ok(()) 108 | } 109 | 110 | #[tokio::test] 111 | async fn json_path_does_not_contain() -> Result<()> { 112 | let mock_server = HttpMockServer::new(); 113 | let mock = mock_server.get_valid_user(); 114 | 115 | let json = r#"{ 116 | "id": 2, 117 | "name": "Unknown" 118 | }"#; 119 | 120 | Grillon::new(&mock_server.server.url("/"))? 121 | .get("users/1") 122 | .assert() 123 | .await 124 | .json_path("$", does_not_contain(json)); 125 | 126 | mock.assert(); 127 | 128 | Ok(()) 129 | } 130 | 131 | #[tokio::test] 132 | async fn json_path_matches() -> Result<()> { 133 | let mock_server = HttpMockServer::new(); 134 | let mock = mock_server.get_valid_user(); 135 | 136 | Grillon::new(&mock_server.server.url("/"))? 137 | .get("users/1") 138 | .assert() 139 | .await 140 | .json_path("$.name", matches("Isa+c")) 141 | .json_path("$.name", matches(r"Isa[a-z]{2}")) 142 | .json_path("$.name", matches("Isaac")); 143 | 144 | mock.assert(); 145 | 146 | Ok(()) 147 | } 148 | 149 | #[tokio::test] 150 | async fn json_path_does_not_match() -> Result<()> { 151 | let mock_server = HttpMockServer::new(); 152 | let mock = mock_server.get_valid_user(); 153 | 154 | Grillon::new(&mock_server.server.url("/"))? 155 | .get("users/1") 156 | .assert() 157 | .await 158 | .json_path("$.name", does_not_match("^Isa$")); 159 | 160 | mock.assert(); 161 | 162 | Ok(()) 163 | } 164 | 165 | #[tokio::test] 166 | #[should_panic] 167 | async fn json_path_with_invalid_regex_pattern() { 168 | let mock_server: HttpMockServer = HttpMockServer::new(); 169 | let mock = mock_server.get_valid_user(); 170 | 171 | Grillon::new(&mock_server.server.url("/")) 172 | .unwrap() 173 | .get("users/1") 174 | .assert() 175 | .await 176 | .json_path("$.name", does_not_match(r"\")); 177 | 178 | mock.assert_hits(0); 179 | } 180 | 181 | #[tokio::test] 182 | #[should_panic] 183 | async fn json_path_regex_fails_with_null_value() { 184 | let mock_server: HttpMockServer = HttpMockServer::new(); 185 | let mock = mock_server.get_valid_user(); 186 | 187 | Grillon::new(&mock_server.server.url("/")) 188 | .unwrap() 189 | .get("users/1") 190 | .assert() 191 | .await 192 | // note the importance of the double quotes to get a valid json `Value` 193 | .json_path("$.unknown", matches(r#""Isaac""#)); 194 | 195 | mock.assert_hits(0); 196 | } 197 | -------------------------------------------------------------------------------- /tests/assert/json_schema.rs: -------------------------------------------------------------------------------- 1 | use crate::HttpMockServer; 2 | use grillon::{ 3 | dsl::{is, schema}, 4 | json, Grillon, Result, 5 | }; 6 | use serde_json::Value; 7 | use std::{fs::File, io::BufReader, path::PathBuf}; 8 | 9 | #[tokio::test] 10 | async fn json_body_matches_schema() -> Result<()> { 11 | let mock_server: HttpMockServer = HttpMockServer::new(); 12 | let mock = mock_server.get_valid_user(); 13 | let json_schema = json!( 14 | { 15 | "type": "object", 16 | "properties": { 17 | "id": { 18 | "type": "number", 19 | "description": "the user ID" 20 | }, 21 | "name": { 22 | "type": "string", 23 | "description": "the user's name" 24 | } 25 | }, 26 | "required": ["id", "name"] 27 | } 28 | ); 29 | 30 | Grillon::new(&mock_server.server.url("/"))? 31 | .get("users/1") 32 | .assert() 33 | .await 34 | .json_body(is(json!({ 35 | "id": 1, 36 | "name": "Isaac", 37 | }))) 38 | .json_body(schema(json_schema)); 39 | 40 | mock.assert(); 41 | 42 | Ok(()) 43 | } 44 | 45 | #[tokio::test] 46 | #[should_panic] 47 | async fn json_body_does_not_match_schema() { 48 | let mock_server: HttpMockServer = HttpMockServer::new(); 49 | mock_server.get_valid_user(); 50 | let json_schema = json!( 51 | { 52 | "type": "object", 53 | "properties": { 54 | "id": { 55 | "type": "number", 56 | "description": "the user ID", 57 | }, 58 | "age": { 59 | "type": "number", 60 | "description": "the user age", 61 | }, 62 | "name": { 63 | "type": "string", 64 | "description": "the user's name" 65 | } 66 | }, 67 | "required": ["id", "name", "age"] 68 | } 69 | ); 70 | 71 | Grillon::new(&mock_server.server.url("/")) 72 | .unwrap() 73 | .get("users/1") 74 | .assert() 75 | .await 76 | .json_body(is(json!({ 77 | "id": 1, 78 | "name": "Isaac", 79 | }))) 80 | .json_body(schema(json_schema)); 81 | } 82 | 83 | #[tokio::test] 84 | async fn json_path_value_matches_schema() -> Result<()> { 85 | let mock_server: HttpMockServer = HttpMockServer::new(); 86 | let mock = mock_server.get_valid_user(); 87 | let json_schema = json!( 88 | { 89 | "type": "array", 90 | "maxItems": 1, 91 | "items": { 92 | "type": "number" 93 | }, 94 | "description": "the user ID from the json path" 95 | } 96 | ); 97 | 98 | Grillon::new(&mock_server.server.url("/"))? 99 | .get("users/1") 100 | .assert() 101 | .await 102 | .json_body(is(json!({ 103 | "id": 1, 104 | "name": "Isaac", 105 | }))) 106 | .json_path("$.id", schema(json_schema)); 107 | 108 | mock.assert(); 109 | 110 | Ok(()) 111 | } 112 | 113 | #[tokio::test] 114 | async fn matches_schema_file_pathbuf() -> Result<()> { 115 | let mock_server: HttpMockServer = HttpMockServer::new(); 116 | let mock = mock_server.get_valid_user(); 117 | let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); 118 | let user_id_schema_file = base_path.join("user_id_schema.json"); 119 | let user_schema_file = base_path.join("user_schema.json"); 120 | 121 | Grillon::new(&mock_server.server.url("/"))? 122 | .get("users/1") 123 | .assert() 124 | .await 125 | .json_body(schema(user_schema_file)) 126 | .json_path("$.id", schema(user_id_schema_file)); 127 | 128 | mock.assert(); 129 | 130 | Ok(()) 131 | } 132 | 133 | #[tokio::test] 134 | async fn matches_schema_string_types() -> Result<()> { 135 | let mock_server: HttpMockServer = HttpMockServer::new(); 136 | let mock = mock_server.get_valid_user(); 137 | let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); 138 | let user_id_schema_file = base_path.join("user_id_schema.json"); 139 | let user_schema_file = base_path.join("user_schema.json"); 140 | 141 | let read_file_value = |path: &PathBuf| -> Value { 142 | let file = File::open(path).unwrap(); 143 | let reader = BufReader::new(file); 144 | 145 | serde_json::from_reader(reader).unwrap() 146 | }; 147 | 148 | Grillon::new(&mock_server.server.url("/"))? 149 | .get("users/1") 150 | .assert() 151 | .await 152 | .json_body(schema(read_file_value(&user_schema_file).to_string())) 153 | .json_body(schema( 154 | read_file_value(&user_schema_file).to_string().as_str(), 155 | )) 156 | .json_path( 157 | "$.id", 158 | schema(read_file_value(&user_id_schema_file).to_string()), 159 | ) 160 | .json_path( 161 | "$.id", 162 | schema(read_file_value(&user_id_schema_file).to_string().as_str()), 163 | ); 164 | 165 | mock.assert(); 166 | 167 | Ok(()) 168 | } 169 | -------------------------------------------------------------------------------- /tests/assert/mod.rs: -------------------------------------------------------------------------------- 1 | mod assert_fn; 2 | mod auth; 3 | mod cookies; 4 | mod headers; 5 | mod json_body; 6 | mod json_path; 7 | mod json_schema; 8 | mod response_time; 9 | mod status; 10 | mod surf_impl; 11 | -------------------------------------------------------------------------------- /tests/assert/response_time.rs: -------------------------------------------------------------------------------- 1 | use crate::HttpMockServer; 2 | use grillon::{dsl::is_less_than, Grillon, Result}; 3 | 4 | #[tokio::test] 5 | async fn response_time_less_than() -> Result<()> { 6 | let mock_server = HttpMockServer::new(); 7 | let mock = mock_server.delete_valid_user(); 8 | 9 | Grillon::new(&mock_server.server.url("/"))? 10 | .delete("users/1") 11 | .assert() 12 | .await 13 | .response_time(is_less_than(100)); 14 | 15 | mock.assert(); 16 | 17 | Ok(()) 18 | } 19 | -------------------------------------------------------------------------------- /tests/assert/status.rs: -------------------------------------------------------------------------------- 1 | use crate::HttpMockServer; 2 | use grillon::{ 3 | dsl::{ 4 | http::{is_client_error, is_server_error, is_success}, 5 | is, is_between, is_not, 6 | }, 7 | Grillon, LogSettings, Result, StatusCode, 8 | }; 9 | 10 | #[tokio::test] 11 | async fn status_success() -> Result<()> { 12 | let mock_server = HttpMockServer::new(); 13 | let mock = mock_server.delete_valid_user(); 14 | 15 | Grillon::new(&mock_server.server.url("/"))? 16 | .delete("users/1") 17 | .assert() 18 | .await 19 | .status(is_between(200, 299)) 20 | .status(is_success()) 21 | .status(is(StatusCode::NO_CONTENT)) 22 | .status(is(204)); 23 | 24 | mock.assert(); 25 | 26 | Ok(()) 27 | } 28 | 29 | #[tokio::test] 30 | async fn status_client_error() -> Result<()> { 31 | let mock_server = HttpMockServer::new(); 32 | 33 | Grillon::new(&mock_server.server.url("/"))? 34 | .get("inexistant/resource") 35 | .assert() 36 | .await 37 | .status(is_between(400, 499)) 38 | .status(is_client_error()) 39 | .status(is(StatusCode::NOT_FOUND)); 40 | 41 | Ok(()) 42 | } 43 | 44 | #[tokio::test] 45 | async fn status_server_error() -> Result<()> { 46 | let mock_server = HttpMockServer::new(); 47 | mock_server.server_error(); 48 | 49 | Grillon::new(&mock_server.server.url("/"))? 50 | .get("server/error") 51 | .assert() 52 | .await 53 | .status(is_between(500, 599)) 54 | .status(is_server_error()) 55 | .status(is(StatusCode::INTERNAL_SERVER_ERROR)); 56 | 57 | Ok(()) 58 | } 59 | 60 | #[tokio::test] 61 | #[should_panic] 62 | async fn unexpected_status() { 63 | let mock_server = HttpMockServer::new(); 64 | 65 | Grillon::new(&mock_server.server.url("/")) 66 | .unwrap() 67 | .get("some/path") 68 | .assert() 69 | .await 70 | .status(is(StatusCode::OK)); 71 | } 72 | 73 | #[tokio::test] 74 | async fn status_is_not() -> Result<()> { 75 | let mock_server = HttpMockServer::new(); 76 | let mock = mock_server.delete_valid_user(); 77 | 78 | Grillon::new(&mock_server.server.url("/"))? 79 | .log_settings(LogSettings::StdAssert) 80 | .delete("users/1") 81 | .assert() 82 | .await 83 | .status(is_between(200, 299)) 84 | .status(is_success()) 85 | .status(is_not(500)) 86 | .status(is_not(StatusCode::INTERNAL_SERVER_ERROR)); 87 | 88 | mock.assert(); 89 | 90 | Ok(()) 91 | } 92 | 93 | #[tokio::test] 94 | async fn status_is_between() -> Result<()> { 95 | let mock_server = HttpMockServer::new(); 96 | let mock = mock_server.delete_valid_user(); 97 | 98 | Grillon::new(&mock_server.server.url("/"))? 99 | .delete("users/1") 100 | .assert() 101 | .await 102 | .status(is_success()) 103 | .status(is_between(200, 204)) 104 | .status(is_between(StatusCode::OK, StatusCode::NO_CONTENT)); 105 | 106 | mock.assert(); 107 | 108 | Ok(()) 109 | } 110 | -------------------------------------------------------------------------------- /tests/assert/surf_impl.rs: -------------------------------------------------------------------------------- 1 | use std::{str::FromStr, time::Instant}; 2 | 3 | use crate::HttpMockServer; 4 | use grillon::{LogSettings, Result}; 5 | use http::{HeaderName, HeaderValue}; 6 | 7 | #[tokio::test] 8 | async fn custom_response_struct() -> Result<()> { 9 | use async_trait::async_trait; 10 | use grillon::{dsl::is_between, header::HeaderMap, Assert, Response, StatusCode}; 11 | use serde_json::Value; 12 | 13 | struct ResponseWrapper { 14 | pub response: surf::Response, 15 | } 16 | 17 | #[async_trait(?Send)] 18 | impl Response for ResponseWrapper { 19 | fn status(&self) -> StatusCode { 20 | let status: u16 = self.response.status().into(); 21 | StatusCode::from_u16(status).expect("Invalid status code range") 22 | } 23 | 24 | async fn json(mut self) -> Option { 25 | if let Ok(bytes) = &self.response.body_bytes().await { 26 | return serde_json::from_slice::(bytes).ok(); 27 | } 28 | 29 | None 30 | } 31 | 32 | fn headers(&self) -> HeaderMap { 33 | let mut headers = HeaderMap::new(); 34 | 35 | let keys = self 36 | .response 37 | .header_names() 38 | .map(|k| { 39 | HeaderName::from_str(k.as_str()).expect("Failed to convert Surf header name") 40 | }) 41 | .collect::>(); 42 | 43 | let values = self 44 | .response 45 | .header_values() 46 | .map(|v| { 47 | HeaderValue::from_str(v.as_str()).expect("Failed to convert Surf header value") 48 | }) 49 | .collect::>(); 50 | 51 | assert_eq!( 52 | keys.len(), 53 | values.len(), 54 | "surf header names vector lenght doesn't match the values length" 55 | ); 56 | 57 | for (k, v) in keys.iter().zip(values.iter()) { 58 | headers.insert(k.clone(), v.clone()); 59 | } 60 | 61 | headers 62 | } 63 | } 64 | 65 | let mock_server = HttpMockServer::new(); 66 | let mock = mock_server.get_valid_user(); 67 | 68 | // HTTP call with a different client (grillon uses reqwest by default) 69 | let now = Instant::now(); 70 | let response = surf::get(mock_server.server.url("/users/1")) 71 | .await 72 | .expect("Valid surf::Response"); 73 | let response_time_ms = now.elapsed().as_millis() as u64; 74 | 75 | let response_wrapper = ResponseWrapper { response }; 76 | 77 | Assert::new( 78 | Some(response_wrapper), 79 | Some(response_time_ms), 80 | LogSettings::default(), 81 | ) 82 | .await 83 | .status(is_between(200, 299)) 84 | .assert_fn(|assert| { 85 | assert!(assert.status == Some(StatusCode::OK), "Bad status code"); 86 | }); 87 | 88 | mock.assert(); 89 | 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /tests/fixtures/inexistant_order.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 9000, 3 | "active": true 4 | } 5 | -------------------------------------------------------------------------------- /tests/fixtures/json_body.json: -------------------------------------------------------------------------------- 1 | { 2 | "a_string": "john", 3 | "a_number": 12, 4 | "a_string_number": "12", 5 | "a_vec": [ 6 | { 7 | "entry1": "entry1" 8 | }, 9 | { 10 | "entry2": "entry2" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /tests/fixtures/order4.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 4, 3 | "active": true 4 | } 5 | -------------------------------------------------------------------------------- /tests/fixtures/orders.json: -------------------------------------------------------------------------------- 1 | { 2 | "orders": [ 3 | { 4 | "id": 1, 5 | "active": true 6 | }, 7 | { 8 | "id": 2 9 | }, 10 | { 11 | "id": 3 12 | }, 13 | { 14 | "id": 4, 15 | "active": true 16 | } 17 | ], 18 | "total": 4 19 | } 20 | -------------------------------------------------------------------------------- /tests/fixtures/orders_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "Order validation schema", 4 | "type": "array", 5 | "items": { 6 | "type": "object", 7 | "required": ["id", "active"], 8 | "properties": { 9 | "id": { 10 | "type": "integer" 11 | }, 12 | "active": { 13 | "type": "boolean" 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/fixtures/user_id_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "array", 3 | "maxItems": 1, 4 | "items": { 5 | "type": "number" 6 | }, 7 | "description": "the user ID from the json path" 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/user_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "id": { 5 | "type": "number", 6 | "description": "the user ID" 7 | }, 8 | "name": { 9 | "type": "string", 10 | "description": "the user's name" 11 | } 12 | }, 13 | "required": ["id", "name"] 14 | } 15 | -------------------------------------------------------------------------------- /tests/http/basic_http.rs: -------------------------------------------------------------------------------- 1 | use crate::HttpMockServer; 2 | use grillon::{ 3 | dsl::{contains, http::is_success, is}, 4 | header::{ 5 | HeaderName, HeaderValue, ACCESS_CONTROL_ALLOW_METHODS, CONTENT_LENGTH, CONTENT_LOCATION, 6 | CONTENT_TYPE, USER_AGENT, 7 | }, 8 | json, Grillon, Method, Result, StatusCode, 9 | }; 10 | 11 | #[tokio::test] 12 | async fn post_request() -> Result<()> { 13 | let mock_server = HttpMockServer::new(); 14 | let mock = mock_server.post_valid_user(); 15 | 16 | let json_header_map = vec![(CONTENT_TYPE, HeaderValue::from_static("application/json"))]; 17 | 18 | Grillon::new(mock_server.server.url("/").as_ref())? 19 | .post("users") 20 | .payload(json!({ 21 | "name": "Isaac", 22 | })) 23 | .headers(json_header_map.clone()) 24 | .assert() 25 | .await 26 | .status(is_success()) 27 | .status(is(StatusCode::CREATED)) 28 | .headers(contains(json_header_map)) 29 | .json_body(is(json!({ 30 | "id": 1, 31 | "name": "Isaac" 32 | }))); 33 | 34 | mock.assert(); 35 | 36 | Ok(()) 37 | } 38 | 39 | #[tokio::test] 40 | async fn get_request() -> Result<()> { 41 | let mock_server = HttpMockServer::new(); 42 | let mock = mock_server.get_valid_user(); 43 | 44 | let json_header_map = vec![(CONTENT_TYPE, HeaderValue::from_static("application/json"))]; 45 | 46 | Grillon::new(mock_server.server.url("/").as_ref())? 47 | .get("users/1") 48 | .headers(json_header_map.clone()) 49 | .assert() 50 | .await 51 | .status(is_success()) 52 | .status(is(StatusCode::OK)) 53 | .headers(contains(json_header_map)) 54 | .json_body(is(json!({ 55 | "id": 1, 56 | "name": "Isaac" 57 | }))); 58 | 59 | mock.assert(); 60 | 61 | Ok(()) 62 | } 63 | 64 | #[tokio::test] 65 | async fn put_request() -> Result<()> { 66 | let mock_server = HttpMockServer::new(); 67 | let mock = mock_server.put_valid_user(); 68 | 69 | Grillon::new(mock_server.server.url("/").as_ref())? 70 | .put("users/1") 71 | .headers(vec![( 72 | CONTENT_TYPE, 73 | HeaderValue::from_static("application/json"), 74 | )]) 75 | .payload(json!({ 76 | "name": "Isaac", 77 | })) 78 | .assert() 79 | .await 80 | .status(is_success()) 81 | .status(is(StatusCode::NO_CONTENT)) 82 | .headers(contains(vec![( 83 | CONTENT_LOCATION, 84 | HeaderValue::from_static("/users/1"), 85 | )])); 86 | 87 | mock.assert(); 88 | 89 | Ok(()) 90 | } 91 | 92 | #[tokio::test] 93 | async fn delete_request() -> Result<()> { 94 | let mock_server = HttpMockServer::new(); 95 | let mock = mock_server.delete_valid_user(); 96 | 97 | Grillon::new(mock_server.server.url("/").as_ref())? 98 | .delete("users/1") 99 | .assert() 100 | .await 101 | .status(is_success()) 102 | .status(is(StatusCode::NO_CONTENT)); 103 | 104 | mock.assert(); 105 | 106 | Ok(()) 107 | } 108 | 109 | #[tokio::test] 110 | async fn patch_request() -> Result<()> { 111 | let mock_server = HttpMockServer::new(); 112 | let mock = mock_server.patch_valid_user(); 113 | 114 | Grillon::new(mock_server.server.url("/").as_ref())? 115 | .patch("users/1") 116 | .payload(json!( 117 | [ 118 | { "op": "replace", "path": "/name", "value": "Isaac 👣" } 119 | ] 120 | )) 121 | .headers(vec![( 122 | CONTENT_TYPE, 123 | HeaderValue::from_static("application/json-patch+json"), 124 | )]) 125 | .assert() 126 | .await 127 | .status(is_success()) 128 | .status(is(StatusCode::NO_CONTENT)) 129 | .headers(contains(vec![( 130 | CONTENT_LOCATION, 131 | HeaderValue::from_static("/users/1"), 132 | )])); 133 | 134 | mock.assert(); 135 | 136 | Ok(()) 137 | } 138 | 139 | #[tokio::test] 140 | async fn options_request() -> Result<()> { 141 | let mock_server = HttpMockServer::new(); 142 | let mock = mock_server.options(); 143 | 144 | Grillon::new(mock_server.server.url("/").as_ref())? 145 | .options("") 146 | .assert() 147 | .await 148 | .status(is_success()) 149 | .status(is(StatusCode::NO_CONTENT)) 150 | .headers(contains(vec![( 151 | ACCESS_CONTROL_ALLOW_METHODS, 152 | HeaderValue::from_static("OPTIONS, GET, HEAD, POST, PUT, DELETE, PATCH"), 153 | )])); 154 | 155 | mock.assert(); 156 | 157 | Ok(()) 158 | } 159 | 160 | #[tokio::test] 161 | async fn head_request() -> Result<()> { 162 | let mock_server = HttpMockServer::new(); 163 | let mock = mock_server.head(); 164 | 165 | Grillon::new(mock_server.server.url("/").as_ref())? 166 | .head("movies/1") 167 | .assert() 168 | .await 169 | .status(is_success()) 170 | .status(is(StatusCode::NO_CONTENT)) 171 | .headers(contains(vec![( 172 | CONTENT_LENGTH, 173 | HeaderValue::from_static("91750400"), 174 | )])); 175 | 176 | mock.assert(); 177 | 178 | Ok(()) 179 | } 180 | 181 | #[tokio::test] 182 | async fn connect_request() -> Result<()> { 183 | let mock_server = HttpMockServer::new(); 184 | let mock = mock_server.connect(); 185 | 186 | Grillon::new(mock_server.server.url("/").as_ref())? 187 | .connect("") 188 | .headers(vec![( 189 | USER_AGENT, 190 | HeaderValue::from_static( 191 | "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko", 192 | ), 193 | )]) 194 | .headers(vec![( 195 | "user-agent", 196 | "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko", 197 | )]) 198 | .assert() 199 | .await 200 | .status(is_success()) 201 | .status(is(StatusCode::OK)) 202 | .headers(contains(vec![( 203 | HeaderName::from_static("proxy-agent"), 204 | HeaderValue::from_static("Netscape-Proxy/1.1"), 205 | )])); 206 | 207 | mock.assert(); 208 | 209 | Ok(()) 210 | } 211 | 212 | #[tokio::test] 213 | async fn generic_http_request() -> Result<()> { 214 | let mock_server = HttpMockServer::new(); 215 | let mock = mock_server.delete_valid_user(); 216 | 217 | Grillon::new(mock_server.server.url("/").as_ref())? 218 | .http_request(Method::DELETE, "users/1") 219 | .assert() 220 | .await 221 | .status(is_success()) 222 | .status(is(StatusCode::NO_CONTENT)); 223 | 224 | mock.assert(); 225 | 226 | Ok(()) 227 | } 228 | -------------------------------------------------------------------------------- /tests/http/https.rs: -------------------------------------------------------------------------------- 1 | use grillon::{ 2 | dsl::{contains, http::is_success}, 3 | header::{HeaderName, HeaderValue}, 4 | Grillon, Result, 5 | }; 6 | 7 | // Test a real https call 8 | #[tokio::test] 9 | async fn create_posts_monitoring() -> Result<()> { 10 | Grillon::new("https://jsonplaceholder.typicode.com")? 11 | .options("") 12 | .assert() 13 | .await 14 | .status(is_success()) 15 | .headers(contains(vec![( 16 | HeaderName::from_static("access-control-allow-methods"), 17 | HeaderValue::from_static("GET,HEAD,PUT,PATCH,POST,DELETE"), 18 | )])); 19 | 20 | Ok(()) 21 | } 22 | -------------------------------------------------------------------------------- /tests/http/mod.rs: -------------------------------------------------------------------------------- 1 | mod basic_http; 2 | mod https; 3 | -------------------------------------------------------------------------------- /tests/http_mock_server.rs: -------------------------------------------------------------------------------- 1 | use base64::prelude::*; 2 | use http::header; 3 | use httpmock::prelude::*; 4 | use httpmock::{ 5 | Method::{CONNECT, HEAD, PATCH}, 6 | Mock, MockServer, 7 | }; 8 | use serde_json::json; 9 | 10 | pub struct HttpMockServer { 11 | pub server: httpmock::MockServer, 12 | } 13 | 14 | impl Default for HttpMockServer { 15 | fn default() -> Self { 16 | Self::new() 17 | } 18 | } 19 | 20 | impl HttpMockServer { 21 | pub fn new() -> Self { 22 | Self { 23 | server: MockServer::start(), 24 | } 25 | } 26 | 27 | pub fn get_valid_user(&self) -> Mock { 28 | self.server.mock(|when, then| { 29 | when.method(GET).path("/users/1"); 30 | then.status(200) 31 | .header("content-type", "application/json") 32 | .header("date", "today") 33 | .json_body(json!({ "id": 1, "name": "Isaac" })); 34 | }) 35 | } 36 | 37 | pub fn post_valid_user(&self) -> Mock { 38 | self.server.mock(|when, then| { 39 | when.method(POST) 40 | .path("/users") 41 | .header("content-type", "application/json") 42 | .json_body(json!({ "name": "Isaac" })); 43 | 44 | then.status(201) 45 | .header("content-type", "application/json") 46 | .json_body(json!({ "id": 1, "name": "Isaac" })); 47 | }) 48 | } 49 | 50 | pub fn put_valid_user(&self) -> Mock { 51 | self.server.mock(|when, then| { 52 | when.method(PUT) 53 | .path("/users/1") 54 | .header("content-type", "application/json") 55 | .json_body(json!({ "name": "Isaac" })); 56 | 57 | then.status(204).header("content-location", "/users/1"); 58 | }) 59 | } 60 | 61 | pub fn delete_valid_user(&self) -> Mock { 62 | self.server.mock(|when, then| { 63 | when.method(DELETE).path("/users/1"); 64 | then.status(204); 65 | }) 66 | } 67 | 68 | pub fn patch_valid_user(&self) -> Mock { 69 | self.server.mock(|when, then| { 70 | when.method(PATCH) 71 | .header("content-type", "application/json-patch+json") 72 | .path("/users/1") 73 | .json_body(json!( 74 | [ 75 | { "op": "replace", "path": "/name", "value": "Isaac 👣" } 76 | ] 77 | )); 78 | then.status(204).header("content-location", "/users/1"); 79 | }) 80 | } 81 | 82 | pub fn options(&self) -> Mock { 83 | self.server.mock(|when, then| { 84 | when.method(OPTIONS).path("/"); 85 | then.status(204).header( 86 | "access-control-allow-methods", 87 | "OPTIONS, GET, HEAD, POST, PUT, DELETE, PATCH", 88 | ); 89 | }) 90 | } 91 | 92 | pub fn head(&self) -> Mock { 93 | self.server.mock(|when, then| { 94 | when.method(HEAD).path("/movies/1"); 95 | then.status(204).header("content-length", "91750400"); 96 | }) 97 | } 98 | 99 | pub fn connect(&self) -> Mock { 100 | self.server.mock(|when, then| { 101 | when.method(CONNECT).header( 102 | "user-agent", 103 | "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko", 104 | ); 105 | then.status(200).header("proxy-agent", "Netscape-Proxy/1.1"); 106 | }) 107 | } 108 | 109 | pub fn get_empty_response(&self) -> Mock { 110 | self.server.mock(|when, then| { 111 | when.method(GET).path("/empty"); 112 | then.status(200); 113 | }) 114 | } 115 | 116 | pub fn server_error(&self) -> Mock { 117 | self.server.mock(|when, then| { 118 | when.method(GET).path("/server/error"); 119 | then.status(500); 120 | }) 121 | } 122 | 123 | pub fn basic_auth(&self) -> Mock { 124 | let base64_user_pwd = BASE64_STANDARD.encode(b"isaac:rayne"); 125 | self.server.mock(|when, then| { 126 | when.method(GET).path("/auth/basic/endpoint").header( 127 | header::AUTHORIZATION.as_str(), 128 | format!("Basic {base64_user_pwd}"), 129 | ); 130 | then.status(200); 131 | }) 132 | } 133 | 134 | pub fn bearer_auth(&self) -> Mock { 135 | self.server.mock(|when, then| { 136 | when.method(GET) 137 | .path("/auth/bearer/endpoint") 138 | .header(header::AUTHORIZATION.as_str(), "Bearer token-123"); 139 | then.status(200); 140 | }) 141 | } 142 | 143 | pub fn session_auth(&self) -> Mock { 144 | self.server.mock(|when, then| { 145 | when.method(POST).path("/auth/session"); 146 | then.status(200) 147 | .header("Set-Cookie", "SESSIONID=123; HttpOnly"); 148 | }) 149 | } 150 | 151 | pub fn session_based_request(&self) -> Mock { 152 | self.server.mock(|when, then| { 153 | when.method(GET) 154 | .path("/auth/session/endpoint") 155 | .cookie("SESSIONID", "123"); 156 | then.status(200); 157 | }) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /tests/lib.rs: -------------------------------------------------------------------------------- 1 | use grillon::{ 2 | dsl::*, 3 | header::{HeaderValue, CONTENT_TYPE}, 4 | json, Grillon, Result, StatusCode, 5 | }; 6 | use http_mock_server::HttpMockServer; 7 | 8 | mod assert; 9 | mod http; 10 | mod http_mock_server; 11 | 12 | #[tokio::test] 13 | async fn reuse_grillon_for_multiple_tests() -> Result<()> { 14 | let mock_server = HttpMockServer::new(); 15 | let mock_post = mock_server.post_valid_user(); 16 | let mock_get = mock_server.get_valid_user(); 17 | let headers = vec![(CONTENT_TYPE, HeaderValue::from_static("application/json"))]; 18 | 19 | let grillon = Grillon::new(mock_server.server.url("/").as_ref())?; 20 | 21 | grillon 22 | .post("users") 23 | .payload(json!({ 24 | "name": "Isaac", 25 | })) 26 | .headers(headers) 27 | .assert() 28 | .await 29 | .status(is(StatusCode::CREATED)); 30 | 31 | mock_post.assert(); 32 | 33 | grillon 34 | .get("users/1") 35 | .assert() 36 | .await 37 | .status(is(StatusCode::OK)) 38 | .json_body(is(json!({ 39 | "id": 1, 40 | "name": "Isaac", 41 | }))); 42 | 43 | mock_get.assert(); 44 | 45 | Ok(()) 46 | } 47 | --------------------------------------------------------------------------------