From 020fdb18771f23b440bb79b015411e08366c5257 Mon Sep 17 00:00:00 2001 From: Renaud Denis Date: Tue, 12 Nov 2024 17:42:50 +0100 Subject: [PATCH] Initial Revision --- .gitignore | 2 + CHANGELOG.md | 24 + CONTRIBUTING.md | 72 +++ Cargo.lock | 394 +++++++++++++++++ Cargo.toml | 25 ++ LICENSE-APACHE | 202 +++++++++ LICENSE-MIT | 21 + README.md | 159 +++++++ examples/advanced.rs | 75 ++++ examples/basic.rs | 66 +++ examples/properties.rs | 80 ++++ src/assertions/base.rs | 611 ++++++++++++++++++++++++++ src/assertions/mod.rs | 3 + src/assertions/property_assertions.rs | 347 +++++++++++++++ src/assertions/property_matcher.rs | 169 +++++++ src/error.rs | 201 +++++++++ src/lib.rs | 233 ++++++++++ src/matchers/mod.rs | 86 ++++ src/matchers/regex.rs | 71 +++ src/matchers/type_matcher.rs | 96 ++++ src/matchers/value.rs | 58 +++ 21 files changed, 2995 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 examples/advanced.rs create mode 100644 examples/basic.rs create mode 100644 examples/properties.rs create mode 100644 src/assertions/base.rs create mode 100644 src/assertions/mod.rs create mode 100644 src/assertions/property_assertions.rs create mode 100644 src/assertions/property_matcher.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/matchers/mod.rs create mode 100644 src/matchers/regex.rs create mode 100644 src/matchers/type_matcher.rs create mode 100644 src/matchers/value.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b39d31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.idea/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7f1accf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2024-11-14 + +### Added + +- Initial release with core JSON testing functionality +- JSONPath-based value extraction and validation +- Fluent assertion API +- Property existence and value validation +- String operations with regex support +- Numeric comparisons +- Array and object validation +- Custom matcher support +- Basic type checking +- Property matching system +- Clear error messages + +[0.1.0]: https://github.com/tylium/json-test-rs/releases/tag/v0.1.0 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bd212e0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to json-test + +Thank you for your interest in contributing to json-test! This document provides guidelines and information about contributing to this project. + +## How to Contribute + +### Bug Reports + +Please file bug reports on the GitHub issue tracker. When filing a bug report, please include: + +- A clear description of the problem +- Minimal steps to reproduce the issue +- What you expected to happen +- What actually happened +- Version of json-test you're using + +### Feature Requests + +Feature requests are welcome! Please submit them on the issue tracker and: + +- Describe the feature you'd like to see +- Explain why this feature would be useful +- Be aware that features should fit within the scope of the library + +### Pull Requests + +We appreciate pull requests! To contribute code: + +1. Fork the repository and create a new branch +2. Write your changes, including tests +3. Update documentation as needed +4. Ensure all tests pass with `cargo test` +5. Submit a pull request + +#### Pull Request Guidelines + +- Add tests for any new functionality +- Update documentation as needed +- Clearly describe your changes in the PR description + +### Testing + +- Write relevant tests for all new functionality +- Ensure existing tests pass +- Include both positive and negative test cases +- Test edge cases + +### Documentation + +- Update documentation for any changed functionality +- Include examples in documentation +- Keep documentation clear and concise +- Check that documentation builds without warnings + +## Release Process + +The maintainers will handle releases. The process includes: + +1. Updating version numbers +2. Updating CHANGELOG.md +3. Creating git tags +4. Publishing to crates.io + +## Getting Help + +If you need help with your contribution: + +- Open a GitHub issue with your question +- Tag it with "question" or "help wanted" +- Be patient and respectful + +Thank you for contributing to json-test! \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..678494e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,394 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cpufeatures" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca741a962e1b0bff6d724a1a0958b686406e853bb14061f218562e1896f95e6" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "json-test" +version = "0.1.0" +dependencies = [ + "anyhow", + "jsonpath-rust", + "pretty_assertions", + "regex", + "serde", + "serde_json", + "test-case", + "thiserror 2.0.3", +] + +[[package]] +name = "jsonpath-rust" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69a61b87f6a55cc6c28fed5739dd36b9642321ce63e4a5e4a4715d69106f4a10" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "libc" +version = "0.2.162" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "pest" +version = "2.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442" +dependencies = [ + "memchr", + "thiserror 1.0.69", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d214365f632b123a47fd913301e14c946c61d1c183ee245fa76eb752e59a02dd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb55586734301717aea2ac313f50b2eb8f60d2fc3dc01d190eefa2e625f60c4e" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75da2a70cf4d9cb76833c990ac9cd3923c9a8905a8929789ce347c84564d03d" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.132" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "test-case-core", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa" +dependencies = [ + "thiserror-impl 2.0.3", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..53e8945 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "json-test" +version = "0.1.0" +edition = "2021" +authors = ["Renaud Denis "] +description = "A testing library for JSON Path assertions in Rust" +license = "MIT OR Apache-2.0" +repository = "https://github.com/tylium/json-test-rs" +documentation = "https://docs.rs/json-test" +readme = "README.md" +keywords = ["testing", "json", "jsonpath", "assertions", "validation"] +categories = ["development-tools::testing", "data-structures"] + +[dependencies] +anyhow = "1" +thiserror = "2" +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +jsonpath-rust = "0" + +[dev-dependencies] +pretty_assertions = "1" +test-case = "3" \ No newline at end of file diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..3c79660 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Tylium + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..ff4c95c --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Tylium + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9981035 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# json-test + +[![Crates.io](https://img.shields.io/crates/v/json-test.svg)](https://crates.io/crates/json-test) +[![Documentation](https://docs.rs/json-test/badge.svg)](https://docs.rs/json-test) +[![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](README.md#license) + +A testing library for JSON Path assertions in Rust, providing a fluent API for validating JSON structures in tests. + +## Features + +- 🔍 JSONPath-based value extraction and validation +- ⛓️ Chainable, fluent assertion API +- 🎯 Type-safe operations +- 🧩 Property existence and value validation +- 📝 String pattern matching with regex support +- 🔢 Numeric comparisons +- 📦 Array and object validation +- 🎨 Custom matcher support + +## Quick Start + +Add to your `Cargo.toml`: +```toml +[dev-dependencies] +json-test = "0.1" +``` + +Basic usage: +```rust +use json_test::JsonTest; +use serde_json::json; + +#[test] +fn test_json_structure() { + let data = json!({ + "user": { + "name": "John Doe", + "age": 30, + "roles": ["user", "admin"] + } + }); + + let mut test = JsonTest::new(&data); + + test.assert_path("$.user.name") + .exists() + .is_string() + .equals(json!("John Doe")); + + test.assert_path("$.user.roles") + .is_array() + .has_length(2) + .contains(&json!("admin")); +} +``` + +## Main Features + +### Path Assertions +```rust +test.assert_path("$.user") + .exists() + .has_property("name") + .has_property_value("age", json!(30)); +``` + +### String Operations +```rust +test.assert_path("$.user.email") + .is_string() + .contains_string("@") + .matches_pattern(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"); +``` + +### Numeric Comparisons +```rust +test.assert_path("$.user.age") + .is_number() + .is_greater_than(18) + .is_less_than(100); +``` + +### Array Operations +```rust +test.assert_path("$.user.roles") + .is_array() + .has_length(2) + .contains(&json!("admin")); +``` + +### Property Matching +```rust +test.assert_path("$.user") + .has_properties(vec!["name", "age", "roles"]) + .properties_matching(|key| key.starts_with("meta_")) + .count(0) + .and() + .has_property_matching("age", |v| v.as_u64().unwrap_or(0) > 18); +``` + +### Custom Matchers +```rust +test.assert_path("$.timestamp") + .matches(|value| { + value.as_str() + .map(|s| s.parse::>().is_ok()) + .unwrap_or(false) + }); +``` + +## Examples + +Looking for more examples? Check out the `examples/` directory which showcases: + +- Basic JSON validation patterns +- Advanced JSONPath queries +- Property matching capabilities + +## Error Messages + +The library provides clear, test-friendly error messages: +```text +Property 'email' not found at $.user +Available properties: name, age, roles + +Array at $.user.roles has wrong length +Expected: 3 +Actual: 2 +``` + +## Status + +This library is in active development (0.1.x). While the core API is stabilizing, minor breaking changes might occur before 1.0. + +## Roadmap + +- Enhanced array operations +- Deep property traversal +- Improved string operations +- Additional numeric assertions + +## Contributing + +Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) for details. + +## License + +Licensed under either of: + +* Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) +* MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in the work by you, as defined in the Apache-2.0 license, shall be +dual licensed as above, without any additional terms or conditions. \ No newline at end of file diff --git a/examples/advanced.rs b/examples/advanced.rs new file mode 100644 index 0000000..2da01ba --- /dev/null +++ b/examples/advanced.rs @@ -0,0 +1,75 @@ +//! Advanced examples showing powerful JSONPath queries and assertions. + +use json_test::JsonTest; +use serde_json::json; + +fn main() { + let data = json!({ + "orders": [ + { + "id": "ord_1", + "customer": "John Doe", + "items": [ + { "product": "Widget", "price": 29.99, "quantity": 2 }, + { "product": "Gadget", "price": 49.99, "quantity": 1 } + ], + "status": "shipped", + "shipping_address": { + "country": "US", + "priority": "express" + } + }, + { + "id": "ord_2", + "customer": "Jane Smith", + "items": [ + { "product": "Widget", "price": 29.99, "quantity": 1 } + ], + "status": "pending", + "shipping_address": { + "country": "UK", + "priority": "standard" + } + } + ], + "stats": { + "total_orders": 2, + "countries": ["US", "UK"], + "average_order_value": 89.98 + } + }); + + let mut test = JsonTest::new(&data); + + test.assert_path("$.orders[?(@.status == 'shipped')].customer") + // Find customer with shipped order using JSONPath filter + .equals(json!("John Doe")) + + // Verify express shipping is for order ord_1 + .assert_path("$.orders[?(@.shipping_address.priority == 'express')].id") + .equals(json!("ord_1")) + + // Check first order's items array + .assert_path("$.orders[0].items") + .is_array() + .has_length(2) + + // Verify shipping countries by checking stats array directly + .assert_path("$.stats.countries") + .is_array() + .contains(&json!("US")) + .contains(&json!("UK")) + + // Validate average order value is in expected range + .assert_path("$.stats.average_order_value") + .is_number() + .is_greater_than(80) + .is_less_than(90) + + // Complex filter: find orders containing Widget product + .assert_path("$.orders[?(@.items[*].product == 'Widget')].status") + .is_array() + .has_length(2); + + println!("All advanced assertions passed!"); +} \ No newline at end of file diff --git a/examples/basic.rs b/examples/basic.rs new file mode 100644 index 0000000..3674833 --- /dev/null +++ b/examples/basic.rs @@ -0,0 +1,66 @@ +//! Basic examples demonstrating the fluent API and JSONPath capabilities. + +use json_test::JsonTest; +use serde_json::json; + +fn main() { + let data = json!({ + "users": [ + { + "id": 1, + "name": "John Doe", + "email": "john@example.com", + "roles": ["admin", "user"], + "active": true + }, + { + "id": 2, + "name": "Jane Smith", + "email": "jane@example.com", + "roles": ["user"], + "active": true + } + ], + "metadata": { + "total_users": 2, + "last_updated": "2024-01-01T12:00:00Z" + } + }); + + let mut test = JsonTest::new(&data); + + test.assert_path("$.users[0].name") + // Verify first user's name exists and is a string + .exists() + .is_string() + .equals(json!("John Doe")) + + // Check first user's roles - should include admin + .assert_path("$.users[0].roles") + .is_array() + .contains(&json!("admin")) + + // Verify second user has exactly one role + .assert_path("$.users[1].roles") + .is_array() + .has_length(1) + + // Validate email format for second user + .assert_path("$.users[1].email") + .contains_string("@") + .matches_pattern(r"^[^@]+@example\.com$") + + // Check user count in metadata + .assert_path("$.metadata.total_users") + .is_number() + .equals(json!(2)) + + // Validate timestamp format in metadata + .assert_path("$.metadata.last_updated") + .is_string() + .starts_with("2024") + .contains_string("T") + .ends_with("Z"); + + println!("All basic assertions passed!"); +} \ No newline at end of file diff --git a/examples/properties.rs b/examples/properties.rs new file mode 100644 index 0000000..c844acb --- /dev/null +++ b/examples/properties.rs @@ -0,0 +1,80 @@ +//! Example showing property matching capabilities. + +use json_test::{JsonTest, PropertyAssertions}; +use serde_json::json; + +fn main() { + let data = json!({ + "config": { + "db_settings": { + "host": "localhost", + "port": 5432, + "max_connections": 100 + }, + "api_keys": { + "key_prod": "pk_live_123", + "key_test": "pk_test_456", + "key_dev": "pk_dev_789" + }, + "feature_flags": { + "debug_mode": false, + "maintenance_mode": false, + "beta_features": true + }, + "limits": { + "max_requests": 1000, + "rate_limit": 60, + "timeout_ms": 5000 + } + } + }); + + let mut test = JsonTest::new(&data); + + test.assert_path("$.config.db_settings") + // Verify all required database properties exist + .has_properties(vec!["host", "port", "max_connections"]) + + // Check database configuration values + .has_property_value("port", json!(5432)) + .has_property_value("host", json!("localhost")) + + // Test API keys section + .assert_path("$.config.api_keys") + // Find all production keys + .properties_matching(|key| key.starts_with("key_prod")) + .count(1) + .and() + // Verify all keys follow pattern + .properties_matching(|key| key.starts_with("key_")) + .count(3) + .all(|(_, value)| { + value.as_str() + .map(|s| s.starts_with("pk_")) + .unwrap_or(false) + }) + .and() + + // Check feature flags + .assert_path("$.config.feature_flags") + // Count disabled features + .properties_matching(|_| true) + .count(3) + .all(|(_, value)| value.is_boolean()) + .and() + // Verify specific flags + .has_property_value("debug_mode", json!(false)) + .has_property_value("beta_features", json!(true)) + + // Validate limits + .assert_path("$.config.limits") + // All limits should be positive numbers + .properties_matching(|_| true) + .all(|(_, value)| { + value.as_u64() + .map(|n| n > 0) + .unwrap_or(false) + }); + + println!("All property assertions passed!"); +} \ No newline at end of file diff --git a/src/assertions/base.rs b/src/assertions/base.rs new file mode 100644 index 0000000..0f50799 --- /dev/null +++ b/src/assertions/base.rs @@ -0,0 +1,611 @@ +use crate::JsonTest; +use jsonpath_rust::JsonPath; +use serde_json::{Map, Value}; +use std::str::FromStr; + +/// Provides assertions for JSON values accessed via JSONPath expressions. +/// +/// This struct is created by `JsonTest::assert_path()` and enables a fluent API +/// for testing JSON values. All assertion methods follow a builder pattern, +/// returning `&mut Self` for chaining. +/// +/// # Examples +/// +/// ```rust +/// use json_test::{JsonTest, PropertyAssertions}; +/// use serde_json::json; +/// +/// let data = json!({ +/// "user": { +/// "name": "John", +/// "age": 30 +/// } +/// }); +/// +/// let mut test = JsonTest::new(&data); +/// test.assert_path("$.user") +/// .exists() +/// .has_property("name") +/// .has_property_value("age", json!(30)); +/// ``` +#[derive(Debug)] +pub struct JsonPathAssertion<'a> { + pub(crate) path_str: String, + pub(crate) current_values: Vec, + pub(crate) test: Option<&'a mut JsonTest<'a>>, +} + +impl<'a> JsonPathAssertion<'a> { + pub(crate) fn new_with_test(test: &'a mut JsonTest<'a>, json: &'a Value, path: &str) -> Self { + let parsed_path = JsonPath::::from_str(path) + .unwrap_or_else(|e| panic!("Invalid JSONPath expression: {}", e)); + + let result = parsed_path.find(json); + let current_values = match result { + Value::Array(values) => { + if !path.contains('[') && values.len() == 1 { + vec![values[0].clone()] + } else { + values + } + } + Value::Null => vec![], + other => vec![other], + }; + + Self { + path_str: path.to_string(), + current_values, + test: Some(test), + } + } + + #[cfg(test)] + pub fn new_for_test(json: &'a Value, path: &str) -> Self { + let parsed_path = JsonPath::::from_str(path) + .unwrap_or_else(|e| panic!("Invalid JSONPath expression: {}", e)); + + let result = parsed_path.find(json); + let current_values = match result { + Value::Array(values) => { + if !path.contains('[') && values.len() == 1 { + vec![values[0].clone()] + } else { + values + } + } + Value::Null => vec![], + other => vec![other], + }; + + Self { + path_str: path.to_string(), + current_values, + test: None, + } + } + + /// Asserts that the path exists and has at least one value. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"user": {"name": "John"}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user.name") + /// .exists(); + /// ``` + /// + /// # Panics + /// + /// Panics if the path does not exist in the JSON structure. + pub fn exists(&'a mut self) -> &'a mut Self { + if self.current_values.is_empty() { + panic!("Path {} does not exist", self.path_str); + } + self + } + + /// Asserts that the path does not exist or has no values. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"user": {"name": "John"}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user.email") + /// .does_not_exist(); + /// ``` + /// + /// # Panics + /// + /// Panics if the path exists in the JSON structure. + pub fn does_not_exist(&'a mut self) -> &'a mut Self { + if !self.current_values.is_empty() { + panic!("Path {} exists but should not. Found values: {:?}", + self.path_str, self.current_values); + } + self + } + + /// Asserts that the value at the current path equals the expected value. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"user": {"name": "John"}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user.name") + /// .equals(json!("John")); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value doesn't match the expected value + pub fn equals(&'a mut self, expected: Value) -> &'a mut Self { + match self.current_values.get(0) { + Some(actual) if actual == &expected => self, + Some(actual) => panic!( + "Value mismatch at {}\nExpected: {}\nActual: {}", + self.path_str, expected, actual + ), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the value at the current path is a string. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"message": "Hello"}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.message") + /// .is_string(); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not a string + pub fn is_string(&'a mut self) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::String(_)) => self, + Some(v) => panic!("Expected string at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the string value contains the given substring. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"email": "test@example.com"}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.email") + /// .contains_string("@example"); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not a string + /// - Panics if the string does not contain the substring + pub fn contains_string(&'a mut self, substring: &str) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::String(s)) if s.contains(substring) => self, + Some(Value::String(s)) => panic!( + "String at {} does not contain '{}'\nActual: {}", + self.path_str, substring, s + ), + Some(v) => panic!("Expected string at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the string value starts with the given prefix. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"id": "user_123"}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.id") + /// .starts_with("user_"); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not a string + /// - Panics if the string does not start with the prefix + pub fn starts_with(&'a mut self, prefix: &str) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::String(s)) if s.starts_with(prefix) => self, + Some(Value::String(s)) => panic!( + "String at {} does not start with '{}'\nActual: {}", + self.path_str, prefix, s + ), + Some(v) => panic!("Expected string at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the string value ends with the given suffix. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"file": "document.pdf"}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.file") + /// .ends_with(".pdf"); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not a string + /// - Panics if the string does not end with the suffix + pub fn ends_with(&'a mut self, suffix: &str) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::String(s)) if s.ends_with(suffix) => self, + Some(Value::String(s)) => panic!( + "String at {} does not end with '{}'\nActual: {}", + self.path_str, suffix, s + ), + Some(v) => panic!("Expected string at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the string value matches the given regular expression pattern. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"email": "test@example.com"}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.email") + /// .matches_pattern(r"^[^@]+@[^@]+\.[^@]+$"); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not a string + /// - Panics if the pattern is invalid + /// - Panics if the string does not match the pattern + + pub fn matches_pattern(&'a mut self, pattern: &str) -> &'a mut Self { + let regex = regex::Regex::new(pattern) + .unwrap_or_else(|e| panic!("Invalid regex pattern: {}", e)); + + match self.current_values.get(0) { + Some(Value::String(s)) if regex.is_match(s) => self, + Some(Value::String(s)) => panic!( + "String at {} does not match pattern '{}'\nActual: {}", + self.path_str, pattern, s + ), + Some(v) => panic!("Expected string at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the value at the current path is a number. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"count": 42}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.count") + /// .is_number(); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not a number + pub fn is_number(&'a mut self) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::Number(_)) => self, + Some(v) => panic!("Expected number at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the numeric value is greater than the given value. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"age": 21}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.age") + /// .is_greater_than(18); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not a number + /// - Panics if the value is not greater than the given value + pub fn is_greater_than(&'a mut self, value: i64) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::Number(n)) if n.as_i64().map_or(false, |x| x > value) => self, + Some(Value::Number(n)) => panic!( + "Number at {} is not greater than {}\nActual: {}", + self.path_str, value, n + ), + Some(v) => panic!("Expected number at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the numeric value is less than the given value. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"temperature": 36}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.temperature") + /// .is_less_than(40); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not a number + /// - Panics if the value is not less than the given value + pub fn is_less_than(&'a mut self, value: i64) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::Number(n)) if n.as_i64().map_or(false, |x| x < value) => self, + Some(Value::Number(n)) => panic!( + "Number at {} is not less than {}\nActual: {}", + self.path_str, value, n + ), + Some(v) => panic!("Expected number at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the numeric value is between the given minimum and maximum values (inclusive). + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"score": 85}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.score") + /// .is_between(0, 100); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not a number + /// - Panics if the value is not between min and max (inclusive) + pub fn is_between(&'a mut self, min: i64, max: i64) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::Number(n)) if n.as_i64().map_or(false, |x| x >= min && x <= max) => self, + Some(Value::Number(n)) => panic!( + "Number at {} is not between {} and {}\nActual: {}", + self.path_str, min, max, n + ), + Some(v) => panic!("Expected number at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the value at the current path is an array. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"tags": ["rust", "testing"]}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.tags") + /// .is_array(); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not an array + pub fn is_array(&'a mut self) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::Array(_)) => self, + Some(v) => panic!("Expected array at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the array has the expected length. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"tags": ["rust", "testing"]}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.tags") + /// .is_array() + /// .has_length(2); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not an array + /// - Panics if the array length doesn't match the expected length + pub fn has_length(&'a mut self, expected: usize) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::Array(arr)) if arr.len() == expected => self, + Some(Value::Array(arr)) => panic!( + "Array at {} has wrong length\nExpected: {}\nActual: {}", + self.path_str, expected, arr.len() + ), + Some(v) => panic!("Expected array at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the array contains the expected value. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"roles": ["user", "admin"]}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.roles") + /// .is_array() + /// .contains(&json!("admin")); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not an array + /// - Panics if the array does not contain the expected value + pub fn contains(&'a mut self, expected: &Value) -> &'a mut Self { + match self.current_values.get(0) { + Some(Value::Array(arr)) if arr.contains(expected) => self, + Some(Value::Array(arr)) => panic!( + "Array at {} does not contain expected value\nExpected: {}\nArray: {:?}", + self.path_str, expected, arr + ), + Some(v) => panic!("Expected array at {}, got {:?}", self.path_str, v), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the value matches a custom predicate. + /// + /// This method allows for complex value validation using custom logic. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"timestamp": "2024-01-01T12:00:00Z"}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.timestamp") + /// .matches(|value| { + /// value.as_str() + /// .map(|s| s.contains("T") && s.ends_with("Z")) + /// .unwrap_or(false) + /// }); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value doesn't satisfy the predicate + pub fn matches(&'a mut self, predicate: F) -> &'a mut Self + where + F: FnOnce(&Value) -> bool, + { + match self.current_values.get(0) { + Some(value) if predicate(value) => self, + Some(value) => panic!( + "Value at {} does not match predicate\nActual value: {}", + self.path_str, value + ), + None => panic!("No value found at {}", self.path_str), + } + } + + /// Asserts that the value is an object and returns it for further testing. + /// + /// This method is primarily used internally by property assertions. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// # let data = json!({"user": {"name": "John", "age": 30}}); + /// # let mut test = JsonTest::new(&data); + /// let obj = test.assert_path("$.user") + /// .assert_object(); + /// assert!(obj.contains_key("name")); + /// ``` + /// + /// # Panics + /// + /// - Panics if no value exists at the path + /// - Panics if the value is not an object + pub fn assert_object(&self) -> Map { + match &self.current_values[..] { + [Value::Object(obj)] => obj.clone(), + _ => panic!( + "Expected object at {}, got: {:?}", + self.path_str, self.current_values + ), + } + } + + /// Creates a new assertion for a different path while maintaining the test context. + /// + /// This method enables chaining assertions across different paths. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({ + /// # "user": {"name": "John"}, + /// # "settings": {"theme": "dark"} + /// # }); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user") + /// .has_property("name") + /// .assert_path("$.settings") + /// .has_property("theme"); + /// ``` + /// + /// # Panics + /// + /// - Panics if called on an assertion without test context + pub fn assert_path(&'a mut self, path: &str) -> JsonPathAssertion<'a> { + match &mut self.test { + Some(test) => test.assert_path(path), + None => panic!("Cannot chain assertions without JsonTest context"), + } + } +} \ No newline at end of file diff --git a/src/assertions/mod.rs b/src/assertions/mod.rs new file mode 100644 index 0000000..d1b2e14 --- /dev/null +++ b/src/assertions/mod.rs @@ -0,0 +1,3 @@ +pub mod base; +pub mod property_assertions; +pub mod property_matcher; \ No newline at end of file diff --git a/src/assertions/property_assertions.rs b/src/assertions/property_assertions.rs new file mode 100644 index 0000000..000a6dc --- /dev/null +++ b/src/assertions/property_assertions.rs @@ -0,0 +1,347 @@ +use serde_json::Value; +use crate::assertions::property_matcher::PropertyMatcher; + +/// Trait providing property testing capabilities for JSON objects. +pub trait PropertyAssertions<'a> { + /// Asserts that the object has the specified property. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"name": "John"}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user") + /// .has_property("name"); + /// ``` + /// + /// # Panics + /// + /// - Panics if the value is not an object + /// - Panics if the property doesn't exist + fn has_property(&'a mut self, name: &str) -> &'a mut Self; + + /// Asserts that the object has all the specified properties. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"name": "John", "age": 30}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user") + /// .has_properties(["name", "age"]); + /// ``` + /// + /// # Panics + /// + /// - Panics if the value is not an object + /// - Panics if any of the properties don't exist + fn has_properties(&'a mut self, names: I) -> &'a mut Self + where + I: IntoIterator, + S: AsRef; + + /// Asserts that the object has exactly the expected number of properties. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"name": "John", "age": 30}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user") + /// .has_property_count(2); + /// ``` + /// + /// # Panics + /// + /// - Panics if the value is not an object + /// - Panics if the number of properties doesn't match the expected countfn has_property_count(&'a mut self, expected: usize) -> &'a mut Self; + fn has_property_count(&'a mut self, expected: usize) -> &'a mut Self; + + /// Asserts that the object has the expected number of properties matching a predicate. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"meta_created": "2024-01-01", "meta_updated": "2024-01-02", "name": "John"}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user") + /// .has_property_count_matching(|key| key.starts_with("meta_"), 2); + /// ``` + /// + /// # Panics + /// + /// - Panics if the value is not an object + /// - Panics if the number of matching properties doesn't equal the expected count + + fn has_property_count_matching(&'a mut self, predicate: F, expected: usize) -> &'a mut Self + where + F: Fn(&str) -> bool; + + /// Asserts that a property has the expected value. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"name": "John", "age": 30}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user") + /// .has_property_value("name", json!("John")); + /// ``` + /// + /// # Panics + /// + /// - Panics if the value is not an object + /// - Panics if the property doesn't exist + /// - Panics if the property value doesn't match the expected value + fn has_property_value(&'a mut self, name: &str, expected: Value) -> &'a mut Self; + + /// Asserts that a property's value satisfies a predicate. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"age": 30}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user") + /// .has_property_matching("age", |v| v.as_u64().unwrap_or(0) > 18); + /// ``` + /// + /// # Panics + /// + /// - Panics if the value is not an object + /// - Panics if the property doesn't exist + /// - Panics if the property value doesn't satisfy the predicate + + fn has_property_matching(&'a mut self, name: &str, predicate: F) -> &'a mut Self + where + F: Fn(&Value) -> bool; + + /// Creates a PropertyMatcher for testing properties that match a predicate. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"meta_created": "2024-01-01", "meta_updated": "2024-01-02"}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user") + /// .properties_matching(|key| key.starts_with("meta_")) + /// .count(2) + /// .and() + /// .has_property_count(2); + /// ``` + fn properties_matching(&'a mut self, predicate: F) -> PropertyMatcher<'a> + where + F: Fn(&str) -> bool; +} + +impl<'a> PropertyAssertions<'a> for super::base::JsonPathAssertion<'a> { + fn has_property(&'a mut self, name: &str) -> &'a mut Self { + let obj = self.assert_object(); + + if !obj.contains_key(name) { + let available = obj.keys() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + + panic!("Property '{}' not found at {}\nAvailable properties: {}", + name, self.path_str, available); + } + self + } + + fn has_properties(&'_ mut self, names: I) -> &'_ mut Self + where + I: IntoIterator, + S: AsRef, + { + let obj = self.assert_object(); + let missing: Vec = names.into_iter() + .filter(|name| !obj.contains_key(name.as_ref())) + .map(|name| name.as_ref().to_string()) + .collect(); + + if !missing.is_empty() { + let available = obj.keys() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + + panic!("Missing properties at {}: {}\nAvailable properties: {}", + self.path_str, missing.join(", "), available); + } + self + } + + fn has_property_count(&'_ mut self, expected: usize) -> &'_ mut Self { + let obj = self.assert_object(); + let actual = obj.len(); + + if actual != expected { + let properties = obj.keys() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + + panic!( + "Incorrect number of properties at {}\nExpected: {}\nActual: {}\nProperties: {}", + self.path_str, expected, actual, properties + ); + } + self + } + + fn has_property_count_matching(&'_ mut self, predicate: F, expected: usize) -> &'_ mut Self + where + F: Fn(&str) -> bool, + { + let obj = self.assert_object(); + let matching: Vec<&str> = obj.keys() + .filter(|k| predicate(k)) + .map(|s| s.as_str()) + .collect(); + + if matching.len() != expected { + panic!( + "Incorrect number of matching properties at {}\nExpected: {}\nActual: {}\nMatching properties: {}", + self.path_str, expected, matching.len(), matching.join(", ") + ); + } + self + } + + fn has_property_value(&'_ mut self, name: &str, expected: Value) -> &'_ mut Self { + let obj = self.assert_object(); + + match obj.get(name) { + Some(actual) if actual == &expected => self, + Some(actual) => { + panic!( + "Property '{}' value mismatch at {}\nExpected: {}\nActual: {}", + name, self.path_str, expected, actual + ); + }, + None => { + let available = obj.keys() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + + panic!( + "Property '{}' not found at {}\nAvailable properties: {}", + name, self.path_str, available + ); + } + } + } + + fn has_property_matching(&'_ mut self, name: &str, predicate: F) -> &'_ mut Self + where + F: Fn(&Value) -> bool, + { + let obj = self.assert_object(); + + match obj.get(name) { + Some(value) if predicate(value) => self, + Some(value) => { + panic!( + "Property '{}' at {} does not match condition\nValue: {}", + name, self.path_str, value + ); + }, + None => { + let available = obj.keys() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + + panic!( + "Property '{}' not found at {}\nAvailable properties: {}", + name, self.path_str, available + ); + } + } + } + + fn properties_matching(&'a mut self, predicate: F) -> PropertyMatcher<'a> + where + F: Fn(&str) -> bool, + { + let obj = self.assert_object(); + let pairs: Vec<(String, Value)> = obj.iter() + .filter(|(k, _)| predicate(k)) + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + + PropertyMatcher::new(pairs, self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assertions::base::JsonPathAssertion; + use serde_json::json; + + #[test] + fn test_property_assertions() { + let json = json!({ + "user": { + "name": "John", + "age": 30, + "metadata": { + "created_at": "2024-01-01", + "updated_at": "2024-01-02" + } + } + }); + + let mut assertion = JsonPathAssertion::new_for_test(&json, "$.user"); + + assertion + .has_property("name") + .has_properties(vec!["name", "age"]) + .has_property_count(3) + .has_property_value("name", json!("John")) + .has_property_matching("age", |v| v.as_u64().unwrap_or(0) > 20) + .has_property_count_matching(|k| k.ends_with("at"), 0); + } + + #[test] + #[should_panic(expected = "Property 'email' not found")] + fn test_missing_property() { + let json = json!({"user": {"name": "John"}}); + let mut assertion = JsonPathAssertion::new_for_test(&json, "$.user"); + assertion.has_property("email"); + } + + #[test] + #[should_panic(expected = "Incorrect number of properties")] + fn test_property_count() { + let json = json!({"user": {"name": "John", "age": 30}}); + let mut assertion = JsonPathAssertion::new_for_test(&json, "$.user"); + assertion.has_property_count(1); + } + + #[test] + #[should_panic(expected = "Property 'age' value mismatch")] + fn test_property_value_mismatch() { + let json = json!({"user": {"name": "John", "age": 30}}); + let mut assertion = JsonPathAssertion::new_for_test(&json, "$.user"); + assertion.has_property_value("age", json!(25)); + } +} \ No newline at end of file diff --git a/src/assertions/property_matcher.rs b/src/assertions/property_matcher.rs new file mode 100644 index 0000000..cf97281 --- /dev/null +++ b/src/assertions/property_matcher.rs @@ -0,0 +1,169 @@ +use serde_json::Value; + +/// Matches and collects properties based on custom predicates. +/// +/// This struct provides advanced property matching capabilities, allowing +/// filtering and validation of object properties that match specific criteria. +/// +/// # Examples +/// +/// ```rust +/// # use json_test::{JsonTest, PropertyAssertions}; +/// # use serde_json::json; +/// # let data = json!({ +/// # "user": { +/// # "name": "John", +/// # "meta_created": "2024-01-01", +/// # "meta_updated": "2024-01-02" +/// # } +/// # }); +/// # let mut test = JsonTest::new(&data); +/// test.assert_path("$.user") +/// .properties_matching(|key| key.starts_with("meta_")) +/// .count(2) +/// .and() +/// .has_property("name"); +/// ``` +pub struct PropertyMatcher<'a> { + pairs: Vec<(String, Value)>, + assertion: &'a mut super::base::JsonPathAssertion<'a>, +} + +impl<'a> PropertyMatcher<'a> { + pub(crate) fn new(pairs: Vec<(String, Value)>, assertion: &'a mut super::base::JsonPathAssertion<'a>) -> Self { + Self { pairs, assertion } + } + + /// Asserts that the number of matching properties equals the expected count. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"meta_created": "2024-01-01", "meta_updated": "2024-01-02"}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user") + /// .properties_matching(|key| key.starts_with("meta_")) + /// .count(2); + /// ``` + /// + /// # Panics + /// + /// Panics if the number of matching properties doesn't equal the expected count. + pub fn count(self, expected: usize) -> Self { + assert_eq!( + self.pairs.len(), + expected, + "Expected {} matching properties but found {} at {}", + expected, + self.pairs.len(), + self.assertion.path_str + ); + self + } + + /// Asserts that all matching properties satisfy a predicate. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"config": {"debug_mode": true, "debug_level": 3}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.config") + /// .properties_matching(|key| key.starts_with("debug_")) + /// .all(|(key, _)| key.len() > 5); + /// ``` + /// + /// # Panics + /// + /// Panics if any matching property fails to satisfy the predicate. + pub fn all(self, predicate: F) -> Self + where + F: Fn((&str, &Value)) -> bool + { + for (k, v) in &self.pairs { + assert!( + predicate((k, v)), + "Property {:?} did not match predicate at {}", + (k, v), + self.assertion.path_str + ); + } + self + } + + /// Collects matching property values into a vector. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"meta_created": "2024-01-01", "meta_updated": "2024-01-02"}}); + /// # let mut test = JsonTest::new(&data); + /// let meta_values = test.assert_path("$.user") + /// .properties_matching(|key| key.starts_with("meta_")) + /// .collect_values(); + /// ``` + pub fn collect_values(self) -> Vec { + self.pairs.into_iter().map(|(_, v)| v).collect() + } + + /// Collects matching property keys into a vector. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"meta_created": "2024-01-01", "meta_updated": "2024-01-02"}}); + /// # let mut test = JsonTest::new(&data); + /// let meta_keys = test.assert_path("$.user") + /// .properties_matching(|key| key.starts_with("meta_")) + /// .collect_keys(); + /// assert_eq!(meta_keys.len(), 2); + /// ``` + pub fn collect_keys(self) -> Vec { + self.pairs.into_iter().map(|(k, _)| k).collect() + } + + /// Collects matching property key-value pairs into a vector. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"meta_created": "2024-01-01", "meta_updated": "2024-01-02"}}); + /// # let mut test = JsonTest::new(&data); + /// let meta_pairs = test.assert_path("$.user") + /// .properties_matching(|key| key.starts_with("meta_")) + /// .collect_pairs(); + /// assert_eq!(meta_pairs.len(), 2); + /// ``` + pub fn collect_pairs(self) -> Vec<(String, Value)> { + self.pairs + } + + /// Returns to the parent assertion for further chaining. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// # let data = json!({"user": {"meta_created": "2024-01-01", "name": "John"}}); + /// # let mut test = JsonTest::new(&data); + /// test.assert_path("$.user") + /// .properties_matching(|key| key.starts_with("meta_")) + /// .count(1) + /// .and() + /// .has_property("name"); + /// ``` + pub fn and(self) -> &'a mut super::base::JsonPathAssertion<'a> { + self.assertion + } +} \ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..4fdec86 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,201 @@ +use std::collections::HashMap; +use serde_json::Value; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum JsonPathError { + #[error("{message}\nPath: {path}\nActual Value: {actual}\n{}", context_string(.context, .expected))] + AssertionFailed { + message: String, + path: String, + actual: Value, + expected: Option, + context: HashMap, + }, + + #[error("Invalid JSONPath expression: {0}")] + InvalidPath(String), +} + +/// Helper function for formatting context in error messages +fn context_string(context: &HashMap, expected: &Option) -> String { + let mut parts = Vec::new(); + + // Add expected value if present + if let Some(exp) = expected { + parts.push(format!("Expected Value: {}", exp)); + } + + // Add all context key-value pairs + for (key, value) in context { + parts.push(format!("{}: {}", key, value)); + } + + parts.join("\n") +} + +impl JsonPathError { + pub fn assertion_failed( + message: impl Into, + path: impl Into, + actual: Value, + expected: Option, + context: HashMap, + ) -> Self { + JsonPathError::AssertionFailed { + message: message.into(), + path: path.into(), + actual, + expected, + context, + } + } + + pub fn type_mismatch(path: String, actual: Value, expected_type: &str) -> Self { + let mut context = HashMap::new(); + context.insert("Expected Type".to_string(), expected_type.to_string()); + context.insert("Actual Type".to_string(), type_name(&actual)); + + JsonPathError::AssertionFailed { + message: format!("Expected value of type {}", expected_type), + path, + actual, + expected: None, + context, + } + } + + pub fn value_mismatch(path: String, actual: Value, expected: Value) -> Self { + let mut context = HashMap::new(); + context.insert("Operation".to_string(), "Equality".to_string()); + + JsonPathError::AssertionFailed { + message: "Value mismatch".to_string(), + path, + actual, + expected: Some(expected), + context, + } + } + + pub fn comparison_failed( + path: String, + actual: Value, + operation: &str, + comparison_value: Value, + ) -> Self { + let mut context = HashMap::new(); + context.insert("Operation".to_string(), operation.to_string()); + context.insert("Comparison Value".to_string(), comparison_value.to_string()); + + JsonPathError::AssertionFailed { + message: format!("Value comparison failed for operation: {}", operation), + path, + actual, + expected: None, + context, + } + } + + pub fn property_error( + path: String, + actual: Value, + message: String, + context: HashMap, + ) -> Self { + JsonPathError::AssertionFailed { + message, + path, + actual, + expected: None, + context, + } + } +} + +/// Helper function to get readable type names +fn type_name(value: &Value) -> String { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + }.to_string() +} + +/// Extension trait for adding context to errors +pub trait ErrorContext { + fn with_context(self, key: K, value: V) -> Result + where + K: Into, + V: Into; + + fn with_contexts(self, contexts: I) -> Result + where + I: IntoIterator, + K: Into, + V: Into; +} + +impl ErrorContext for Result { + fn with_context(self, key: K, value: V) -> Result + where + K: Into, + V: Into, + { + self.map_err(|err| { + if let JsonPathError::AssertionFailed { + message, + path, + actual, + expected, + mut context, + } = err + { + context.insert(key.into(), value.into()); + JsonPathError::AssertionFailed { + message, + path, + actual, + expected, + context, + } + } else { + err + } + }) + } + + fn with_contexts(self, contexts: I) -> Result + where + I: IntoIterator, + K: Into, + V: Into, + { + self.map_err(|err| { + if let JsonPathError::AssertionFailed { + message, + path, + actual, + expected, + mut context, + } = err + { + for (k, v) in contexts { + context.insert(k.into(), v.into()); + } + JsonPathError::AssertionFailed { + message, + path, + actual, + expected, + context, + } + } else { + err + } + }) + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..00db893 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,233 @@ +//! A testing library for JSON Path assertions in Rust. +//! +//! `json-test` provides a fluent API for testing JSON structures using JSONPath expressions. +//! It's designed to make writing tests for JSON data structures clear, concise, and maintainable. +//! +//! # Core Concepts +//! +//! - **JsonTest**: The main entry point, providing methods to start assertions +//! - **JsonPathAssertion**: Chainable assertions on JSON values +//! - **PropertyAssertions**: Object property validation +//! - **Matchers**: Flexible value matching and validation +//! +//! # Features +//! +//! - JSONPath-based value extraction and validation +//! - Chainable, fluent assertion API +//! - Type-safe operations +//! - Property existence and value validation +//! - String pattern matching with regex support +//! - Numeric comparisons +//! - Array and object validation +//! - Custom matcher support +//! +//! # Examples +//! +//! ## Value Assertions +//! +//! ```rust +//! use json_test::JsonTest; +//! use serde_json::json; +//! +//! let data = json!({ +//! "user": { +//! "name": "John Doe", +//! "age": 30 +//! } +//! }); +//! +//! let mut test = JsonTest::new(&data); +//! +//! // Chain multiple assertions on a single value +//! test.assert_path("$.user.name") +//! .exists() +//! .is_string() +//! .equals(json!("John Doe")); +//! ``` +//! +//! ## Numeric Validation +//! +//! ```rust +//! # use json_test::JsonTest; +//! # use serde_json::json; +//! # let data = json!({"score": 85}); +//! # let mut test = JsonTest::new(&data); +//! test.assert_path("$.score") +//! .is_number() +//! .is_greater_than(80) +//! .is_less_than(90) +//! .is_between(0, 100); +//! ``` +//! +//! ## Array Testing +//! +//! ```rust +//! # use json_test::JsonTest; +//! # use serde_json::json; +//! # let data = json!({"roles": ["user", "admin"]}); +//! # let mut test = JsonTest::new(&data); +//! test.assert_path("$.roles") +//! .is_array() +//! .has_length(2) +//! .contains(&json!("admin")); +//! ``` +//! +//! ## Property Chaining +//! +//! ```rust +//! # use json_test::{JsonTest, PropertyAssertions}; +//! # use serde_json::json; +//! let data = json!({ +//! "user": { +//! "name": "John", +//! "settings": { +//! "theme": "dark", +//! "notifications": true +//! } +//! } +//! }); +//! +//! let mut test = JsonTest::new(&data); +//! +//! // Chain property assertions +//! test.assert_path("$.user") +//! .has_property("name") +//! .has_property("settings") +//! .properties_matching(|key| !key.starts_with("_")) +//! .count(2) +//! .and() +//! .has_property_value("name", json!("John")); +//! ``` +//! +//! ## Advanced Matching +//! +//! ```rust +//! # use json_test::JsonTest; +//! # use serde_json::json; +//! # let data = json!({"user": {"email": "test@example.com"}}); +//! # let mut test = JsonTest::new(&data); +//! test.assert_path("$.user.email") +//! .is_string() +//! .contains_string("@") +//! .matches_pattern(r"^[^@]+@[^@]+\.[^@]+$") +//! .matches(|value| { +//! value.as_str() +//! .map(|s| !s.starts_with("admin@")) +//! .unwrap_or(false) +//! }); +//! ``` +//! +//! # Error Messages +//! +//! The library provides clear, test-friendly error messages: +//! +//! ```text +//! Property 'email' not found at $.user +//! Available properties: name, age, roles +//! ``` +//! +//! ```text +//! Value mismatch at $.user.age +//! Expected: 25 +//! Actual: 30 +//! ``` +//! +//! # Current Status +//! +//! This library is in active development (0.1.x). While the core API is stabilizing, +//! minor breaking changes might occur before 1.0. + +mod assertions; +mod error; +mod matchers; + +pub use assertions::base::JsonPathAssertion; +pub use assertions::property_assertions::PropertyAssertions; +pub use error::{ErrorContext, JsonPathError}; +pub use matchers::{JsonMatcher, RegexMatcher, TypeMatcher, ValueMatcher}; +use serde_json::Value; + +/// Main entry point for JSON testing. +/// +/// `JsonTest` provides methods to create assertions on JSON values using JSONPath expressions. +/// It maintains a reference to the JSON being tested and enables creation of chainable assertions. +/// +/// # Examples +/// +/// ```rust +/// use json_test::{JsonTest, PropertyAssertions}; +/// use serde_json::json; +/// +/// let data = json!({ +/// "user": { +/// "name": "John", +/// "settings": { +/// "theme": "dark" +/// } +/// } +/// }); +/// +/// let mut test = JsonTest::new(&data); +/// +/// // Test a single path with chained assertions +/// test.assert_path("$.user") +/// .has_property("name") +/// .has_property("settings") +/// .has_property_value("name", json!("John")); +/// ``` +#[derive(Debug)] +pub struct JsonTest<'a> { + json: &'a Value, +} + +impl<'a> JsonTest<'a> { + /// Creates a new JSON test instance. + /// + /// Takes a reference to a JSON value that will be tested. The JSON value + /// must live at least as long as the test instance. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::JsonTest; + /// # use serde_json::json; + /// let data = json!({"key": "value"}); + /// let test = JsonTest::new(&data); + /// ``` + pub fn new(json: &'a Value) -> Self { + Self { json } + } + + /// Creates a new assertion for the given JSONPath expression. + /// + /// The path must be a valid JSONPath expression. Invalid paths will cause + /// a panic with a descriptive error message. + /// + /// # Examples + /// + /// ```rust + /// # use json_test::{JsonTest, PropertyAssertions}; + /// # use serde_json::json; + /// let data = json!({ + /// "users": [ + /// {"name": "John", "role": "admin"}, + /// {"name": "Jane", "role": "user"} + /// ] + /// }); + /// + /// let mut test = JsonTest::new(&data); + /// + /// // Test array element with chained assertions + /// test.assert_path("$.users[0]") + /// .has_property("name") + /// .has_property_value("role", json!("admin")); + /// ``` + /// + /// # Panics + /// + /// Panics if the JSONPath expression is invalid. This is appropriate for + /// testing scenarios where invalid paths indicate test specification errors. + pub fn assert_path(&'a mut self, path: &str) -> JsonPathAssertion<'a> { + JsonPathAssertion::new_with_test(self, self.json, path) + } +} \ No newline at end of file diff --git a/src/matchers/mod.rs b/src/matchers/mod.rs new file mode 100644 index 0000000..b9cafba --- /dev/null +++ b/src/matchers/mod.rs @@ -0,0 +1,86 @@ +mod regex; +mod type_matcher; +mod value; + +pub use regex::RegexMatcher; +pub use type_matcher::TypeMatcher; +pub use value::ValueMatcher; + +use serde_json::Value; +use std::fmt::Debug; + +/// Core trait for implementing JSON value matchers. +/// +/// This trait allows creation of custom matchers for flexible value validation. +/// Implementors must provide both matching logic and a description of the matcher. +/// +/// # Examples +/// +/// ```rust +/// use json_test::JsonMatcher; +/// use serde_json::Value; +/// +/// #[derive(Debug)] +/// struct DateMatcher; +/// +/// impl JsonMatcher for DateMatcher { +/// fn matches(&self, value: &Value) -> bool { +/// value.as_str() +/// .map(|s| s.contains("-")) +/// .unwrap_or(false) +/// } +/// +/// fn description(&self) -> String { +/// "is a date string".to_string() +/// } +/// } +/// ``` +pub trait JsonMatcher: Debug { + /// Tests if a JSON value matches this matcher's criteria. + /// + /// # Arguments + /// + /// * `value` - The JSON value to test + /// + /// # Returns + /// + /// Returns `true` if the value matches the criteria, `false` otherwise. + fn matches(&self, value: &Value) -> bool; + + /// Returns a human-readable description of the matcher's criteria. + /// + /// This description is used in error messages when assertions fail. + fn description(&self) -> String; +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Simple test matcher implementation + #[derive(Debug)] + struct TestMatcher(bool); + + impl JsonMatcher for TestMatcher { + fn matches(&self, _: &Value) -> bool { + self.0 + } + + fn description(&self) -> String { + format!("always returns {}", self.0) + } + } + + #[test] + fn test_matcher_trait() { + let value = json!(42); + + let true_matcher = TestMatcher(true); + assert!(true_matcher.matches(&value)); + assert_eq!(true_matcher.description(), "always returns true"); + + let false_matcher = TestMatcher(false); + assert!(!false_matcher.matches(&value)); + } +} \ No newline at end of file diff --git a/src/matchers/regex.rs b/src/matchers/regex.rs new file mode 100644 index 0000000..564489c --- /dev/null +++ b/src/matchers/regex.rs @@ -0,0 +1,71 @@ +use super::JsonMatcher; +use regex::Regex; +use serde_json::Value; + +#[derive(Debug)] +pub struct RegexMatcher { + pattern: Regex, +} + +impl RegexMatcher { + pub fn new(pattern: &str) -> Result { + Ok(Self { + pattern: Regex::new(pattern)? + }) + } +} + +impl JsonMatcher for RegexMatcher { + fn matches(&self, value: &Value) -> bool { + match value { + Value::String(s) => self.pattern.is_match(s), + _ => false, + } + } + + fn description(&self) -> String { + format!("matches regex pattern {}", self.pattern.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_regex_matching() { + let matcher = RegexMatcher::new(r"^\d{4}-\d{2}-\d{2}$").unwrap(); + + // Test valid date format + assert!(matcher.matches(&json!("2024-01-01"))); + + // Test invalid formats + assert!(!matcher.matches(&json!("2024/01/01"))); + assert!(!matcher.matches(&json!("not-a-date"))); + + // Test non-string values + assert!(!matcher.matches(&json!(42))); + assert!(!matcher.matches(&json!(true))); + assert!(!matcher.matches(&json!(null))); + } + + #[test] + fn test_case_sensitive_matching() { + let matcher = RegexMatcher::new(r"[a-z]+\d+").unwrap(); + + assert!(matcher.matches(&json!("test123"))); + assert!(!matcher.matches(&json!("TEST123"))); + } + + #[test] + fn test_invalid_regex() { + assert!(RegexMatcher::new(r"[invalid").is_err()); + } + + #[test] + fn test_description() { + let matcher = RegexMatcher::new(r"\d+").unwrap(); + assert_eq!(matcher.description(), r#"matches regex pattern \d+"#); + } +} \ No newline at end of file diff --git a/src/matchers/type_matcher.rs b/src/matchers/type_matcher.rs new file mode 100644 index 0000000..613da18 --- /dev/null +++ b/src/matchers/type_matcher.rs @@ -0,0 +1,96 @@ +use super::JsonMatcher; +use serde_json::Value; + +#[derive(Debug)] +pub struct TypeMatcher { + expected_type: &'static str, +} + +impl TypeMatcher { + pub fn new(expected_type: &'static str) -> Self { + Self { expected_type } + } + + // Convenience constructors + pub fn string() -> Self { + Self::new("string") + } + + pub fn number() -> Self { + Self::new("number") + } + + pub fn boolean() -> Self { + Self::new("boolean") + } + + pub fn array() -> Self { + Self::new("array") + } + + pub fn object() -> Self { + Self::new("object") + } + + pub fn null() -> Self { + Self::new("null") + } +} + +impl JsonMatcher for TypeMatcher { + fn matches(&self, value: &Value) -> bool { + match (self.expected_type, value) { + ("string", Value::String(_)) => true, + ("number", Value::Number(_)) => true, + ("boolean", Value::Bool(_)) => true, + ("null", Value::Null) => true, + ("array", Value::Array(_)) => true, + ("object", Value::Object(_)) => true, + _ => false, + } + } + + fn description(&self) -> String { + format!("is of type {}", self.expected_type) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_type_matching() { + // Test string type + assert!(TypeMatcher::string().matches(&json!("test"))); + assert!(!TypeMatcher::string().matches(&json!(42))); + + // Test number type + assert!(TypeMatcher::number().matches(&json!(42))); + assert!(TypeMatcher::number().matches(&json!(42.5))); + assert!(!TypeMatcher::number().matches(&json!("42"))); + + // Test boolean type + assert!(TypeMatcher::boolean().matches(&json!(true))); + assert!(!TypeMatcher::boolean().matches(&json!(1))); + + // Test array type + assert!(TypeMatcher::array().matches(&json!([1, 2, 3]))); + assert!(!TypeMatcher::array().matches(&json!({"key": "value"}))); + + // Test object type + assert!(TypeMatcher::object().matches(&json!({"key": "value"}))); + assert!(!TypeMatcher::object().matches(&json!([1, 2, 3]))); + + // Test null type + assert!(TypeMatcher::null().matches(&json!(null))); + assert!(!TypeMatcher::null().matches(&json!(42))); + } + + #[test] + fn test_descriptions() { + assert_eq!(TypeMatcher::string().description(), "is of type string"); + assert_eq!(TypeMatcher::number().description(), "is of type number"); + } +} \ No newline at end of file diff --git a/src/matchers/value.rs b/src/matchers/value.rs new file mode 100644 index 0000000..355b322 --- /dev/null +++ b/src/matchers/value.rs @@ -0,0 +1,58 @@ +use super::JsonMatcher; +use serde_json::Value; + +#[derive(Debug)] +pub struct ValueMatcher { + expected: Value, +} + +impl ValueMatcher { + pub fn new(expected: Value) -> Self { + Self { expected } + } + + pub fn eq(expected: Value) -> Self { + Self::new(expected) + } +} + +impl JsonMatcher for ValueMatcher { + fn matches(&self, value: &Value) -> bool { + &self.expected == value + } + + fn description(&self) -> String { + format!("equals {}", self.expected) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_value_matching() { + let value = json!(42); + assert!(ValueMatcher::eq(json!(42)).matches(&value)); + assert!(!ValueMatcher::eq(json!(43)).matches(&value)); + + let obj = json!({"name": "test", "value": 42}); + assert!(ValueMatcher::eq(json!({"name": "test", "value": 42})).matches(&obj)); + assert!(!ValueMatcher::eq(json!({"name": "other"})).matches(&obj)); + } + + #[test] + fn test_array_matching() { + let arr = json!([1, 2, 3]); + assert!(ValueMatcher::eq(json!([1, 2, 3])).matches(&arr)); + assert!(!ValueMatcher::eq(json!([3, 2, 1])).matches(&arr)); + } + + #[test] + fn test_null_matching() { + let null = json!(null); + assert!(ValueMatcher::eq(json!(null)).matches(&null)); + assert!(!ValueMatcher::eq(json!(42)).matches(&null)); + } +} \ No newline at end of file