├── .gitignore ├── scripts ├── fmt.sh └── ci.sh ├── rustfmt.toml ├── .github └── workflows │ ├── dependabot.yml │ └── ci.yml ├── deny.toml ├── LICENSE ├── src ├── validators │ ├── mod.rs │ ├── object.rs │ ├── primitive.rs │ └── array.rs ├── lib.rs ├── macros_utils.rs └── macros.rs ├── Cargo.toml ├── tests └── error_msg.rs └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /scripts/fmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | cargo +nightly fmt --all 6 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | group_imports = "StdExternalCrate" # Unstable 2 | imports_granularity = "Module" # Unstable 3 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "monthly" 11 | -------------------------------------------------------------------------------- /scripts/ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | start=$(date -Iseconds -u) 6 | host_name=$(hostname) 7 | echo "Starting build at: ${start} on ${host_name}" 8 | 9 | export RUST_BACKTRACE="full" 10 | 11 | cargo deny check 12 | cargo +nightly fmt --all -- --check 13 | cargo build --verbose 14 | cargo test --verbose --all-features 15 | cargo clippy --workspace --all-targets --all-features -- --deny warnings 16 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # configuration for https://github.com/EmbarkStudios/cargo-deny 2 | [graph] 3 | all-features = true 4 | 5 | [licenses] 6 | confidence-threshold = 0.8 7 | allow = [ 8 | "Apache-2.0", 9 | "MIT", 10 | ] 11 | 12 | [advisories] 13 | yanked = "deny" 14 | 15 | [bans] 16 | multiple-versions = "deny" 17 | wildcards = 'deny' 18 | 19 | [sources] 20 | unknown-registry = "deny" 21 | unknown-git = "deny" 22 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Charles Vandevoorde 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 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build-test: 14 | name: Build and test (${{ matrix.os }}) 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu-latest 20 | - macos-latest 21 | - windows-latest 22 | 23 | runs-on: ${{ matrix.os }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: swatinem/rust-cache@v2 28 | - name: Build 29 | run: > 30 | cargo build 31 | --verbose 32 | 33 | - name: Run tests (without coverage) 34 | run: > 35 | cargo test 36 | --verbose 37 | 38 | check-clippy-and-format: 39 | name: Check clippy and format 40 | 41 | runs-on: ubuntu-latest 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: swatinem/rust-cache@v2 46 | 47 | - name: Set up nightly toolchain 48 | # Cannot be `minimal` profile, need `rustfmt` and `clippy`: 49 | # https://rust-lang.github.io/rustup/concepts/profiles.html#profiles 50 | run: > 51 | rustup toolchain install nightly 52 | && rustup component add --toolchain nightly rustfmt 53 | 54 | - name: Check formatting 55 | run: > 56 | cargo +nightly fmt 57 | --all 58 | -- --check 59 | 60 | - name: Check clippy 61 | run: > 62 | cargo clippy 63 | --workspace 64 | --all-targets 65 | --all-features 66 | -- --deny warnings 67 | 68 | cargo-deny: 69 | runs-on: ubuntu-latest 70 | strategy: 71 | matrix: 72 | checks: 73 | - advisories 74 | - bans licenses sources 75 | 76 | # Prevent sudden announcement of a new advisory from failing ci: 77 | continue-on-error: ${{ matrix.checks == 'advisories' }} 78 | 79 | steps: 80 | - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 81 | - uses: EmbarkStudios/cargo-deny-action@8371184bd11e21dcf8ac82ebf8c9c9f74ebf7268 82 | with: 83 | command: check ${{ matrix.checks }} 84 | -------------------------------------------------------------------------------- /src/validators/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use crate::{get_value_type_id, Error, Validator, Value}; 4 | 5 | mod array; 6 | mod object; 7 | mod primitive; 8 | 9 | pub use array::*; 10 | pub use object::*; 11 | pub use primitive::*; 12 | 13 | /// Match any value. 14 | /// 15 | /// It will never return an error. 16 | #[must_use] 17 | pub fn any() -> impl Validator { 18 | AnyValidator {} 19 | } 20 | 21 | struct AnyValidator {} 22 | 23 | impl Validator for AnyValidator { 24 | fn validate<'a>(&self, _: &'a Value) -> Result<(), Error<'a>> { 25 | Ok(()) 26 | } 27 | } 28 | 29 | /// Match a value equals the expected value. 30 | pub fn eq(expected: T) -> impl Validator 31 | where 32 | T: Into + Clone + Debug + 'static, 33 | { 34 | EqValidator { expected } 35 | } 36 | 37 | struct EqValidator 38 | where 39 | T: Into + Clone + Debug, 40 | { 41 | expected: T, 42 | } 43 | 44 | impl Validator for EqValidator 45 | where 46 | T: Into + Clone + Debug, 47 | { 48 | fn validate<'a>(&self, value: &'a Value) -> Result<(), Error<'a>> { 49 | let expected_val = self.expected.clone().into(); 50 | if get_value_type_id(&expected_val) != get_value_type_id(value) { 51 | return Err(Error::InvalidType( 52 | value, 53 | get_value_type_id(&expected_val).to_string(), 54 | )); 55 | } 56 | 57 | if value == &expected_val { 58 | Ok(()) 59 | } else { 60 | Err(Error::InvalidValue(value, format!("{:?}", self.expected))) 61 | } 62 | } 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use crate::{Error, Validator, Value}; 68 | 69 | #[test] 70 | fn any() { 71 | let validator = super::any(); 72 | 73 | assert_eq!(Ok(()), validator.validate(&Value::Null)); 74 | } 75 | 76 | #[test] 77 | fn eq_string() { 78 | let validator = super::eq("test"); 79 | 80 | assert_eq!(Ok(()), validator.validate(&serde_json::json!("test"))); 81 | } 82 | 83 | #[test] 84 | fn eq_string_fail() { 85 | let validator = super::eq(String::from("test")); 86 | 87 | assert!(matches!( 88 | validator.validate(&serde_json::json!("not expected")), 89 | Err(Error::InvalidValue(_, _)) 90 | )); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "assert_json" 3 | description = "json testing made simple" 4 | version = "0.1.0" 5 | edition = "2021" 6 | license = "MIT" 7 | license-file = "LICENSE" 8 | homepage = "https://github.com/charlesvdv/assert_json" 9 | repository = "https://github.com/charlesvdv/assert_json" 10 | readme = "README.md" 11 | keywords = ["json", "test", "testing", "assert"] 12 | categories = ["development-tools", "development-tools::testing"] 13 | 14 | [dependencies] 15 | serde_json = "1.0" 16 | codespan-reporting = "0.11" 17 | 18 | [dev-dependencies] 19 | indoc = "2.0" 20 | strip-ansi-escapes = "0.2" 21 | 22 | [lints.rust] 23 | # Lint groups are set to warn so new lints are used as they become available 24 | future_incompatible = { level = "warn", priority = -1 } 25 | let_underscore = { level = "warn", priority = -1 } 26 | nonstandard-style = { level = "warn", priority = -1 } 27 | rust_2018_compatibility = { level = "warn", priority = -1 } 28 | rust_2018_idioms = { level = "warn", priority = -1 } 29 | rust_2021_compatibility = { level = "warn", priority = -1 } 30 | unused = { level = "warn", priority = -1 } 31 | warnings = { level = "warn", priority = -1 } 32 | 33 | # 2024 compatibility is allow for now and will be fixed in a near-future PR 34 | rust_2024_compatibility = { level = "allow", priority = -2 } 35 | 36 | # We also warn on a set of individual lints that are ont included in any group 37 | dead_code = "warn" 38 | trivial_casts = "warn" 39 | trivial_numeric_casts = "warn" 40 | unsafe_code = "warn" 41 | unused_import_braces = "warn" 42 | unused_lifetimes = "warn" 43 | unused_macro_rules = "warn" 44 | unused_qualifications = "warn" 45 | 46 | # These lints are allowed, but we want to deny them over time 47 | async_fn_in_trait = "allow" 48 | 49 | [lints.clippy] 50 | # Lint groups are set to warn so new lints are used as they become available 51 | complexity = { level = "warn", priority = -1 } 52 | correctness = { level = "warn", priority = -1 } 53 | pedantic = { level = "warn", priority = -1 } 54 | perf = { level = "warn", priority = -1 } 55 | style = { level = "warn", priority = -1 } 56 | suspicious = { level = "warn", priority = -1 } 57 | 58 | # Cherry pick lints from the `restriction` group 59 | dbg_macro = "warn" 60 | print_stdout = "warn" 61 | print_stderr = "warn" 62 | 63 | # These lints are explicitly allowed as they don't provide value for this crate. 64 | missing_panics_doc = "allow" # this lib is designed to be used in `#[cfg(test)]` code 65 | missing_errors_doc = "allow" # this lib is designed to be used in `#[cfg(test)]` code 66 | implicit_hasher = "allow" # this lib is designed to be used in `#[cfg(test)]` code 67 | 68 | # These lints are allowed, but we want to deny them over time 69 | module_name_repetitions = "allow" 70 | should_panic_without_expect = "allow" 71 | -------------------------------------------------------------------------------- /tests/error_msg.rs: -------------------------------------------------------------------------------- 1 | use std::any::Any; 2 | use std::io::IsTerminal as _; 3 | 4 | use assert_json::{assert_json, validators}; 5 | use indoc::indoc; 6 | 7 | macro_rules! assert_panic_output { 8 | ($expected_output:expr, $($assert:tt)+) => {{ 9 | let out_result = std::panic::catch_unwind(|| $($assert)+); 10 | let err = out_result_to_string(out_result); 11 | let expected_output = $expected_output.trim(); 12 | assert!(err.contains(expected_output), "\n\texpected:\n{expected_output}\n\tgot:\n{err}") 13 | }}; 14 | } 15 | 16 | #[expect(unsafe_code)] 17 | fn out_result_to_string(result: Result<(), Box>) -> String { 18 | let err = result.unwrap_err(); 19 | let s = err 20 | .downcast::() 21 | .expect("the assert output should be a String"); 22 | 23 | // ANSI escapes should only be written when `assert_json!` is called from a terminal 24 | if std::io::stderr().is_terminal() { 25 | let bytes = strip_ansi_escapes::strip(s.into_bytes()); 26 | unsafe { String::from_utf8_unchecked(bytes) } 27 | } else { 28 | *s 29 | } 30 | } 31 | 32 | #[test] 33 | fn primitive_invalid_type() { 34 | let expected_output = indoc! {r" 35 | │ 36 | 1 │ true 37 | │ ^^^^ Invalid type. Expected number but got bool. 38 | "}; 39 | 40 | assert_panic_output!(expected_output, assert_json!("true", 5)); 41 | } 42 | 43 | #[test] 44 | fn missing_object_key() { 45 | let expected_output = indoc! {r#" 46 | 1 │ ╭ { 47 | 2 │ │ "key": "val" 48 | 3 │ │ } 49 | │ ╰─^ Missing key 'missing_key' in object 50 | "#}; 51 | 52 | assert_panic_output!( 53 | expected_output, 54 | assert_json!(r#"{ "key": "val" }"#, { 55 | "key": "val", 56 | "missing_key": null, 57 | }) 58 | ); 59 | } 60 | 61 | #[test] 62 | fn test_readme_example() { 63 | // If the error is updated, don't forget to update the README! 64 | let expected_output = indoc! {r#" 65 | │ 66 | 4 │ "name": "incorrect name" 67 | │ ^^^^^^^^^^^^^^^^ Invalid value. Expected "charlesvdv" but got "incorrect name". 68 | "#}; 69 | let json = r#" 70 | { 71 | "status": "success", 72 | "result": { 73 | "id": 5, 74 | "name": "incorrect name" 75 | } 76 | } 77 | "#; 78 | assert_panic_output!( 79 | expected_output, 80 | assert_json!(json, { 81 | "status": "success", 82 | "result": { 83 | "id": validators::u64(|&v| if v > 0 { Ok(())} else { Err(String::from("id should be greater than 0")) }), 84 | "name": "charlesvdv", 85 | } 86 | } 87 | ) 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/validators/object.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::{Error, Validator, Value}; 4 | 5 | /// Match if each key/value pair matches 6 | /// 7 | /// Ignore key that are not specified. Use [`object_strict`] if you want to 8 | /// exactly match all the key/values. 9 | #[must_use] 10 | pub fn object(key_validators: HashMap>) -> impl Validator { 11 | ObjectValidator { 12 | key_validators, 13 | strict: false, 14 | } 15 | } 16 | 17 | /// Match if each key/value pairs matches. Fail if a key is missing in the validators. 18 | #[must_use] 19 | pub fn object_strict(key_validators: HashMap>) -> impl Validator { 20 | ObjectValidator { 21 | key_validators, 22 | strict: true, 23 | } 24 | } 25 | 26 | /// Match if the object is empty. 27 | #[must_use] 28 | pub fn object_empty() -> impl Validator { 29 | ObjectValidator { 30 | key_validators: HashMap::new(), 31 | strict: true, 32 | } 33 | } 34 | 35 | struct ObjectValidator { 36 | key_validators: HashMap>, 37 | strict: bool, 38 | } 39 | 40 | impl Validator for ObjectValidator { 41 | fn validate<'a>(&self, value: &'a Value) -> Result<(), Error<'a>> { 42 | let object = value 43 | .as_object() 44 | .ok_or_else(|| Error::InvalidType(value, String::from("object")))?; 45 | 46 | for (key, validator) in &self.key_validators { 47 | let inner_value = object 48 | .get(key) 49 | .ok_or_else(|| Error::MissingObjectKey(value, key.clone()))?; 50 | 51 | validator.validate(inner_value)?; 52 | } 53 | 54 | if self.strict { 55 | // Make sure there is no other keys than the one defined in the validator 56 | // if we are in strict mode. 57 | for (key, value) in object { 58 | self.key_validators 59 | .get(key) 60 | .ok_or_else(|| Error::UnexpectedObjectKey(value, key.clone())) 61 | .map(|_| ())?; 62 | } 63 | } 64 | 65 | Ok(()) 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use std::collections::HashMap; 72 | 73 | use crate::{validators, Error, Validator}; 74 | 75 | #[test] 76 | fn valid() { 77 | let mut key_validators: HashMap> = HashMap::new(); 78 | key_validators.insert( 79 | String::from("key"), 80 | Box::new(validators::string(|_| Ok(()))), 81 | ); 82 | key_validators.insert(String::from("key1"), Box::new(validators::any())); 83 | 84 | let validator = super::object(key_validators); 85 | assert_eq!( 86 | Ok(()), 87 | validator.validate(&serde_json::json!({"key": "val", "key1": null})) 88 | ); 89 | } 90 | 91 | #[test] 92 | fn missing_key() { 93 | let mut key_validators: HashMap> = HashMap::new(); 94 | key_validators.insert( 95 | String::from("key"), 96 | Box::new(validators::string(|_| Ok(()))), 97 | ); 98 | 99 | let validator = super::object(key_validators); 100 | assert!(matches!( 101 | validator.validate(&serde_json::json!({})), 102 | Err(Error::MissingObjectKey(_, _)) 103 | )); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/validators/primitive.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Validator, Value}; 2 | 3 | /// Match if string match predicate. 4 | pub fn string(predicate: F) -> impl Validator 5 | where 6 | F: Fn(&String) -> Result<(), String> + 'static, 7 | { 8 | PrimitiveValidator { 9 | typename: String::from("string"), 10 | extract: |val| val.as_str().map(String::from), 11 | predicate, 12 | } 13 | } 14 | 15 | /// Match if null. 16 | #[must_use] 17 | pub fn null() -> impl Validator { 18 | PrimitiveValidator { 19 | typename: String::from("null"), 20 | extract: serde_json::Value::as_null, 21 | predicate: |()| Ok(()), 22 | } 23 | } 24 | 25 | /// Match if bool match predicate. 26 | pub fn bool(predicate: F) -> impl Validator 27 | where 28 | F: Fn(&bool) -> Result<(), String> + 'static, 29 | { 30 | PrimitiveValidator { 31 | typename: String::from("bool"), 32 | extract: serde_json::Value::as_bool, 33 | predicate, 34 | } 35 | } 36 | 37 | /// Match if number match predicate. 38 | pub fn i64(predicate: F) -> impl Validator 39 | where 40 | F: Fn(&i64) -> Result<(), String> + 'static, 41 | { 42 | PrimitiveValidator { 43 | typename: String::from("i64"), 44 | extract: serde_json::Value::as_i64, 45 | predicate, 46 | } 47 | } 48 | 49 | /// Match if number match predicate. 50 | pub fn u64(predicate: F) -> impl Validator 51 | where 52 | F: Fn(&u64) -> Result<(), String> + 'static, 53 | { 54 | PrimitiveValidator { 55 | typename: String::from("u64"), 56 | extract: serde_json::Value::as_u64, 57 | predicate, 58 | } 59 | } 60 | 61 | /// Match if number match predicate. 62 | pub fn f64(predicate: F) -> impl Validator 63 | where 64 | F: Fn(&f64) -> Result<(), String> + 'static, 65 | { 66 | PrimitiveValidator { 67 | typename: String::from("f64"), 68 | extract: serde_json::Value::as_f64, 69 | predicate, 70 | } 71 | } 72 | 73 | struct PrimitiveValidator 74 | where 75 | F: Fn(&T) -> Result<(), String>, 76 | G: Fn(&Value) -> Option, 77 | { 78 | typename: String, 79 | extract: G, 80 | predicate: F, 81 | } 82 | 83 | impl Validator for PrimitiveValidator 84 | where 85 | F: Fn(&T) -> Result<(), String>, 86 | G: Fn(&Value) -> Option, 87 | { 88 | fn validate<'a>(&self, value: &'a Value) -> Result<(), Error<'a>> { 89 | let val = (self.extract)(value) 90 | .ok_or_else(|| Error::InvalidType(value, self.typename.clone()))?; 91 | 92 | (self.predicate)(&val).map_err(|msg| Error::InvalidValue(value, msg)) 93 | } 94 | } 95 | 96 | #[cfg(test)] 97 | mod tests { 98 | use crate::{Error, Validator, Value}; 99 | 100 | #[test] 101 | fn string() { 102 | let validator = super::string(|_| Ok(())); 103 | 104 | assert_eq!(Ok(()), validator.validate(&Value::String("ok".to_string()))); 105 | } 106 | 107 | #[test] 108 | fn string_invalid_value() { 109 | let validator = super::string(|_| Err(String::from("error message"))); 110 | 111 | assert!(matches!( 112 | validator.validate(&Value::String(String::new())), 113 | Err(Error::InvalidValue(_, _)) 114 | )); 115 | } 116 | 117 | #[test] 118 | fn string_invalid_type() { 119 | let validator = super::string(|_| Ok(())); 120 | 121 | assert!(matches!( 122 | validator.validate(&Value::Null), 123 | Err(Error::InvalidType(_, _)) 124 | )); 125 | } 126 | 127 | #[test] 128 | fn null() { 129 | let validator = super::null(); 130 | 131 | assert_eq!(Ok(()), validator.validate(&Value::Null)); 132 | } 133 | 134 | #[test] 135 | fn i64() { 136 | let validator = super::i64(|_| Ok(())); 137 | 138 | assert_eq!(Ok(()), validator.validate(&serde_json::json!(4))); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # assert_json 2 | 3 | [![ci](https://github.com/charlesvdv/assert_json/actions/workflows/ci.yml/badge.svg)](https://github.com/charlesvdv/assert_json/actions/workflows/ci.yml) 4 | ![Crates.io](https://img.shields.io/crates/v/assert_json) 5 | ![docs.rs](https://img.shields.io/docsrs/assert_json) 6 | 7 | A easy and declarative way to test JSON input in Rust. 8 | `assert_json` is a Rust macro heavily inspired by serde [json macro](https://docs.serde.rs/serde_json/macro.json.html). 9 | Instead of creating a JSON value from a JSON literal, `assert_json` makes sure 10 | the JSON input conforms to the validation rules specified. 11 | 12 | `assert_json` also output beautiful error message when a validation error occurs. 13 | 14 | ## How to use 15 | 16 | ```rust 17 | use assert_json::assert_json; 18 | use assert_json::validators; 19 | 20 | #[test] 21 | fn test_json_ok() { 22 | let json = r#" 23 | { 24 | "status": "success", 25 | "result": { 26 | "age": 26, 27 | "name": "charlesvdv" 28 | } 29 | } 30 | "#; 31 | 32 | let name = "charlesvdv"; 33 | 34 | assert_json!(json, { 35 | "status": "success", 36 | "result": { 37 | "age": validators::u64(|&v| if v >= 18 { Ok(())} else { Err(String::from("age should be greater or equal than 18")) }), 38 | "name": name, 39 | } 40 | } 41 | ); 42 | } 43 | ``` 44 | 45 | Any variables or expressions are interpoled as validation rules matching the type and value 46 | of the variable/expression passed to the macro. 47 | 48 | Now, if JSON input is changed to something incorrect like this: 49 | 50 | ```diff 51 | let json = r#" 52 | { 53 | "status": "success", 54 | "result": { 55 | "age": 26, 56 | - "name": "charlesvdv" 57 | + "name": "incorrect name" 58 | } 59 | } 60 | "#; 61 | ``` 62 | 63 | You will get an comprehensible error message like this one: 64 | 65 | ``` 66 | thread 'xxxx' panicked at 'error: Invalid JSON 67 | ┌─ :4:17 68 | │ 69 | 4 │ "name": "incorrect name" 70 | │ ^^^^^^^^^^^^^^^^ Invalid value. Expected "charlesvdv" but got "incorrect name". 71 | ``` 72 | 73 | ### Custom validators 74 | 75 | A set of validators are already implemented in the `validators` module. 76 | If required, one can also creates its own validation routine by implementing the `Validator` trait. 77 | 78 | ```rust 79 | use assert_json::{assert_json, Error, Validator, Value}; 80 | 81 | fn optional_string(expected: Option) -> impl Validator { 82 | OptionalStringValidator { expected } 83 | } 84 | 85 | /// Matches a null JSON value if expected is None, else check if the strings 86 | /// are equals 87 | struct OptionalStringValidator { 88 | expected: Option, 89 | } 90 | 91 | impl Validator for OptionalStringValidator { 92 | fn validate<'a>(&self, value: &'a Value) -> Result<(), Error<'a>> { 93 | if let Some(expected_str) = &self.expected { 94 | let string_value = value 95 | .as_str() 96 | .ok_or_else(|| Error::InvalidType(value, String::from("string")))?; 97 | 98 | if expected_str == string_value { 99 | Ok(()) 100 | } else { 101 | Err(Error::InvalidValue(value, expected_str.clone())) 102 | } 103 | } else { 104 | value.as_null() 105 | .ok_or_else(|| Error::InvalidType(value, String::from("null"))) 106 | } 107 | } 108 | } 109 | 110 | let json = r#" 111 | { 112 | "key": "value", 113 | "none": null 114 | } 115 | "#; 116 | assert_json!(json, { 117 | "key": optional_string(Some(String::from("value"))), 118 | "none": optional_string(None), 119 | }); 120 | ``` 121 | 122 | ## Alternatives 123 | 124 | - [assert-json-diff](https://github.com/davidpdrsn/assert-json-diff) 125 | 126 | 127 | ## Acknowledgments 128 | 129 | Thanks a lot to the [serde-rs/json](https://github.com/serde-rs/json) project members 130 | and especially those who contributed to the `json!` macro. -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A easy and declarative way to test JSON input in Rust. 2 | //! 3 | //! [`assert_json`!] is a Rust macro heavily inspired by [`serde_json::json`!] macro. 4 | //! Instead of creating a JSON value from a JSON literal, [`assert_json`!] makes sure 5 | //! the JSON input conforms to the validation rules specified. 6 | //! 7 | //! [`assert_json`!] also output beautiful error message when a validation error occurs. 8 | //! 9 | //! ``` 10 | //! # use assert_json::assert_json; 11 | //! # use assert_json::validators; 12 | //! # 13 | //! #[test] 14 | //! fn test_json_ok() { 15 | //! let json = r#" 16 | //! { 17 | //! "status": "success", 18 | //! "result": { 19 | //! "age": 26, 20 | //! "name": "charlesvdv" 21 | //! } 22 | //! } 23 | //! "#; 24 | //! 25 | //! let name = "charlesvdv"; 26 | //! 27 | //! assert_json!(json, { 28 | //! "status": "success", 29 | //! "result": { 30 | //! "age": validators::u64(|&v| if v >= 18 { Ok(())} else { Err(String::from("age should be greater or equal than 18")) }), 31 | //! "name": name, 32 | //! } 33 | //! } 34 | //! ); 35 | //! } 36 | //! ``` 37 | 38 | use core::fmt; 39 | 40 | /// A JSON-value. Used by the [Validator] trait. 41 | pub type Value = serde_json::Value; 42 | 43 | fn get_value_type_id(val: &Value) -> &'static str { 44 | match val { 45 | serde_json::Value::Null => "null", 46 | serde_json::Value::Bool(_) => "bool", 47 | serde_json::Value::Number(_) => "number", 48 | serde_json::Value::String(_) => "string", 49 | serde_json::Value::Array(_) => "array", 50 | serde_json::Value::Object(_) => "object", 51 | } 52 | } 53 | 54 | /// Validation error 55 | #[derive(Debug, PartialEq)] 56 | pub enum Error<'a> { 57 | InvalidType(&'a Value, String), 58 | InvalidValue(&'a Value, String), 59 | MissingObjectKey(&'a Value, String), 60 | UnexpectedObjectKey(&'a Value, String), 61 | UnmatchedValidator(&'a Value, usize), 62 | } 63 | 64 | impl<'a> std::error::Error for Error<'a> {} 65 | 66 | impl<'a> fmt::Display for Error<'a> { 67 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 68 | match self { 69 | Self::InvalidType(v, s) => write!( 70 | f, 71 | "Invalid type. Expected {} but got {}.", 72 | s, 73 | get_value_type_id(v) 74 | ), 75 | Self::InvalidValue(v, s) => write!(f, "Invalid value. Expected {s} but got {v}."), 76 | Self::MissingObjectKey(_v, s) => write!(f, "Missing key '{s}' in object"), 77 | Self::UnexpectedObjectKey(_v, s) => write!(f, "Key '{s}' is not expected in object"), 78 | Self::UnmatchedValidator(_v, s) => write!(f, "No match for expected array element {s}"), 79 | } 80 | } 81 | } 82 | 83 | impl<'a> Error<'a> { 84 | fn location(&self) -> &'a Value { 85 | match self { 86 | Error::InvalidType(loc, _) 87 | | Error::InvalidValue(loc, _) 88 | | Error::MissingObjectKey(loc, _) 89 | | Error::UnexpectedObjectKey(loc, _) 90 | | Error::UnmatchedValidator(loc, _) => loc, 91 | } 92 | } 93 | } 94 | 95 | /// Abstract the validation action for [`assert_json`!] macro. 96 | /// 97 | /// Any custom validation rule can be easily use in the macro 98 | /// by implementing the [`Validator::validate`] method. 99 | /// 100 | /// ``` 101 | /// use assert_json::{assert_json, Error, Validator, Value}; 102 | /// 103 | /// fn optional_string(expected: Option) -> impl Validator { 104 | /// OptionalStringValidator { expected } 105 | /// } 106 | /// 107 | /// /// Matches a null JSON value if expected is None, else check if the strings 108 | /// /// are equals 109 | /// struct OptionalStringValidator { 110 | /// expected: Option, 111 | /// } 112 | /// 113 | /// impl Validator for OptionalStringValidator { 114 | /// fn validate<'a>(&self, value: &'a Value) -> Result<(), Error<'a>> { 115 | /// if let Some(expected_str) = &self.expected { 116 | /// let string_value = value 117 | /// .as_str() 118 | /// .ok_or_else(|| Error::InvalidType(value, String::from("string")))?; 119 | /// 120 | /// if expected_str == string_value { 121 | /// Ok(()) 122 | /// } else { 123 | /// Err(Error::InvalidValue(value, expected_str.clone())) 124 | /// } 125 | /// } else { 126 | /// value.as_null() 127 | /// .ok_or_else(|| Error::InvalidType(value, String::from("null"))) 128 | /// } 129 | /// } 130 | /// } 131 | /// 132 | /// let json = r#" 133 | /// { 134 | /// "key": "value", 135 | /// "none": null 136 | /// } 137 | /// "#; 138 | /// assert_json!(json, { 139 | /// "key": optional_string(Some(String::from("value"))), 140 | /// "none": optional_string(None), 141 | /// }); 142 | /// ``` 143 | pub trait Validator { 144 | fn validate<'a>(&self, value: &'a Value) -> Result<(), Error<'a>>; 145 | 146 | fn and(self, validator: T) -> And 147 | where 148 | Self: Sized, 149 | T: Validator, 150 | { 151 | And { 152 | first: self, 153 | second: validator, 154 | } 155 | } 156 | } 157 | 158 | #[doc(hidden)] 159 | pub struct And { 160 | first: T, 161 | second: U, 162 | } 163 | 164 | impl Validator for And 165 | where 166 | T: Validator, 167 | U: Validator, 168 | { 169 | fn validate<'a>(&self, value: &'a Value) -> Result<(), Error<'a>> { 170 | self.first.validate(value).and(self.second.validate(value)) 171 | } 172 | } 173 | 174 | /// Custom validators for different JSON types 175 | pub mod validators; 176 | 177 | #[macro_use] 178 | mod macros; 179 | #[doc(hidden)] 180 | pub mod macros_utils; 181 | -------------------------------------------------------------------------------- /src/validators/array.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use crate::{validators, Error, Validator, Value}; 4 | 5 | /// Match each array element to a specific validator. 6 | #[must_use] 7 | pub fn array(array_validators: Vec>) -> impl Validator { 8 | ArrayValidator { 9 | validators: array_validators, 10 | } 11 | } 12 | 13 | /// Match the array size. 14 | #[must_use] 15 | #[expect(trivial_casts)] 16 | pub fn array_size(expected_size: usize) -> impl Validator { 17 | ArrayValidator { 18 | validators: (0..expected_size) 19 | .map(|_| Box::new(validators::any()) as Box) 20 | .collect(), 21 | } 22 | } 23 | 24 | /// Match empty array. 25 | #[must_use] 26 | pub fn array_empty() -> impl Validator { 27 | ArrayValidator { validators: vec![] } 28 | } 29 | 30 | struct ArrayValidator { 31 | validators: Vec>, 32 | } 33 | 34 | impl Validator for ArrayValidator { 35 | fn validate<'a>(&self, value: &'a Value) -> Result<(), Error<'a>> { 36 | let value_vec = value 37 | .as_array() 38 | .ok_or_else(|| Error::InvalidType(value, String::from("array")))?; 39 | 40 | if value_vec.len() != self.validators.len() { 41 | return Err(Error::InvalidValue( 42 | value, 43 | format!( 44 | "expected {} elements got {}", 45 | self.validators.len(), 46 | value_vec.len() 47 | ), 48 | )); 49 | } 50 | 51 | value_vec 52 | .iter() 53 | .zip(self.validators.iter()) 54 | .try_for_each(|(val, validator)| validator.validate(val)) 55 | } 56 | } 57 | 58 | /// Each supplied validator matches a different array element, in any order. 59 | #[must_use] 60 | pub fn array_contains(validators: Vec>) -> impl Validator { 61 | UnorderedArrayValidator { validators } 62 | } 63 | 64 | struct UnorderedArrayValidator { 65 | validators: Vec>, 66 | } 67 | 68 | impl Validator for UnorderedArrayValidator { 69 | fn validate<'a>(&self, value: &'a Value) -> Result<(), Error<'a>> { 70 | let value_vec = value 71 | .as_array() 72 | .ok_or_else(|| Error::InvalidType(value, String::from("array")))?; 73 | let mut matched_values: HashSet = HashSet::new(); 74 | for (m, validator) in self.validators.iter().enumerate() { 75 | if let Some((n, _)) = value_vec 76 | .iter() 77 | .enumerate() 78 | .filter(|(n, _)| !matched_values.contains(n)) 79 | .find(|(_, v)| validator.validate(v).is_ok()) 80 | { 81 | matched_values.insert(n); 82 | } else { 83 | return Err(Error::UnmatchedValidator(value, m)); 84 | } 85 | } 86 | Ok(()) 87 | } 88 | } 89 | 90 | /// Match if each element match the validator 91 | pub fn array_for_each(validator: impl Validator) -> impl Validator { 92 | ArrayForEachValidator { validator } 93 | } 94 | 95 | struct ArrayForEachValidator 96 | where 97 | T: Validator, 98 | { 99 | validator: T, 100 | } 101 | 102 | impl Validator for ArrayForEachValidator 103 | where 104 | T: Validator, 105 | { 106 | fn validate<'a>(&self, value: &'a Value) -> Result<(), Error<'a>> { 107 | let value_vec = value 108 | .as_array() 109 | .ok_or_else(|| Error::InvalidType(value, String::from("array")))?; 110 | 111 | value_vec 112 | .iter() 113 | .try_for_each(|val| self.validator.validate(val)) 114 | } 115 | } 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use crate::{validators, Error, Validator}; 120 | 121 | #[test] 122 | fn non_array() { 123 | let validator = super::array(vec![]); 124 | 125 | assert!(matches!( 126 | validator.validate(&serde_json::json!(null)), 127 | Err(Error::InvalidType(_, _)) 128 | )); 129 | } 130 | 131 | #[test] 132 | fn empty() { 133 | let validator = super::array(vec![]); 134 | 135 | assert_eq!(Ok(()), validator.validate(&serde_json::json!([]))); 136 | } 137 | 138 | #[test] 139 | fn with_different_value() { 140 | let validator = super::array(vec![ 141 | Box::new(validators::eq(5)), 142 | Box::new(validators::null()), 143 | ]); 144 | 145 | assert_eq!(Ok(()), validator.validate(&serde_json::json!([5, null,]))); 146 | } 147 | 148 | #[test] 149 | fn different_size() { 150 | let validator = super::array(vec![]); 151 | 152 | assert!(matches!( 153 | validator.validate(&serde_json::json!([null])), 154 | Err(Error::InvalidValue(_, _)) 155 | )); 156 | } 157 | 158 | #[test] 159 | fn non_matching_array_value() { 160 | let validator = super::array(vec![Box::new(validators::null())]); 161 | 162 | assert!(matches!( 163 | validator.validate(&serde_json::json!([5])), 164 | Err(Error::InvalidType(_, _)) 165 | )); 166 | } 167 | 168 | #[test] 169 | fn array_size() { 170 | let validator = super::array_size(3); 171 | 172 | assert_eq!( 173 | Ok(()), 174 | validator.validate(&serde_json::json!([4, "test", 3.4])) 175 | ); 176 | } 177 | 178 | #[test] 179 | fn array_contains() { 180 | let validator = validators::array_contains(vec![ 181 | Box::new(validators::eq(1)), 182 | Box::new(validators::eq(2)), 183 | ]); 184 | 185 | assert_eq!(Ok(()), validator.validate(&serde_json::json!([3, 2, 1]))); 186 | } 187 | 188 | #[test] 189 | fn array_contains_repetition() { 190 | let validator = validators::array_contains(vec![ 191 | Box::new(validators::eq(1)), 192 | Box::new(validators::eq(1)), 193 | ]); 194 | 195 | assert!(matches!( 196 | validator.validate(&serde_json::json!([3, 1])), 197 | Err(Error::UnmatchedValidator(_, _)), 198 | )); 199 | } 200 | 201 | #[test] 202 | fn array_does_not_contain() { 203 | let validator = validators::array_contains(vec![ 204 | Box::new(validators::eq(1)), 205 | Box::new(validators::eq(2)), 206 | ]); 207 | 208 | assert!(matches!( 209 | validator.validate(&serde_json::json!([3, 1])), 210 | Err(Error::UnmatchedValidator(_, _)), 211 | )); 212 | } 213 | 214 | #[test] 215 | fn for_each() { 216 | let validator = super::array_for_each(validators::eq(String::from("test"))); 217 | 218 | assert_eq!( 219 | Ok(()), 220 | validator.validate(&serde_json::json!(["test", "test", "test"])) 221 | ); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/macros_utils.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | use std::io::IsTerminal as _; 3 | use std::ops::Range; 4 | 5 | use codespan_reporting::diagnostic::{Diagnostic, Label}; 6 | use codespan_reporting::files::SimpleFiles; 7 | use codespan_reporting::term; 8 | use codespan_reporting::term::termcolor; 9 | 10 | use crate::{validators, Error, Validator, Value}; 11 | 12 | pub struct Input(Value); 13 | 14 | impl Input { 15 | #[must_use] 16 | pub fn get(self) -> Value { 17 | self.0 18 | } 19 | } 20 | 21 | impl From<&str> for Input { 22 | fn from(str_input: &str) -> Input { 23 | let value = serde_json::from_str(str_input).expect("failed to parse JSON"); 24 | Input(value) 25 | } 26 | } 27 | 28 | impl From for Input { 29 | fn from(value: Value) -> Input { 30 | Input(value) 31 | } 32 | } 33 | 34 | pub struct ValidatorInput(Box); 35 | 36 | impl ValidatorInput { 37 | #[must_use] 38 | pub fn get(self) -> Box { 39 | self.0 40 | } 41 | } 42 | 43 | #[doc(hidden)] 44 | macro_rules! impl_from_validator_input_default { 45 | ( 46 | $($ty:ty),* 47 | ) => { 48 | $( 49 | impl From<$ty> for ValidatorInput { 50 | #[inline] 51 | fn from(u: $ty) -> Self { 52 | ValidatorInput(Box::new(validators::eq(u))) 53 | } 54 | } 55 | )* 56 | }; 57 | } 58 | 59 | impl_from_validator_input_default!( 60 | String, bool, u8, u16, u32, u64, usize, i8, i16, i32, i64, isize, f32, f64 61 | ); 62 | 63 | impl From<&str> for ValidatorInput { 64 | fn from(str_input: &str) -> Self { 65 | ValidatorInput(Box::new(validators::eq(String::from(str_input)))) 66 | } 67 | } 68 | 69 | impl From for ValidatorInput 70 | where 71 | T: Validator + 'static, 72 | { 73 | fn from(validator: T) -> Self { 74 | ValidatorInput(Box::new(validator)) 75 | } 76 | } 77 | 78 | #[must_use] 79 | pub fn format_error<'a>(json: &'a Value, error: &Error<'a>) -> String { 80 | let serializer = SpanSerializer::serialize(json); 81 | 82 | let mut files = SimpleFiles::new(); 83 | let file = files.add("", serializer.serialized_json()); 84 | 85 | let diagnostic = Diagnostic::error() 86 | .with_message("Invalid JSON") 87 | .with_labels(vec![Label::primary( 88 | file, 89 | serializer.span(error.location()), 90 | ) 91 | .with_message(error.to_string())]); 92 | 93 | let config = term::Config::default(); 94 | let bytes = Vec::::new(); 95 | 96 | let bytes = if std::io::stderr().is_terminal() { 97 | let mut writer = termcolor::Ansi::new(bytes); 98 | term::emit(&mut writer, &config, &files, &diagnostic).unwrap(); 99 | writer.into_inner() 100 | } else { 101 | let mut writer = termcolor::NoColor::new(bytes); 102 | term::emit(&mut writer, &config, &files, &diagnostic).unwrap(); 103 | writer.into_inner() 104 | }; 105 | 106 | String::from_utf8(bytes).unwrap() 107 | } 108 | 109 | /// Serialize a JSON [Value] and keeps the span information of each 110 | /// elements. 111 | #[derive(Default)] 112 | struct SpanSerializer { 113 | spans: BTreeMap<*const Value, Range>, 114 | json: String, 115 | current_ident: usize, 116 | } 117 | 118 | impl SpanSerializer { 119 | fn serialize(input: &Value) -> SpanSerializer { 120 | let mut serializer = SpanSerializer::default(); 121 | serializer.serialize_recursive(input); 122 | serializer 123 | } 124 | 125 | fn serialize_recursive(&mut self, input: &Value) { 126 | let start = self.json.len(); 127 | 128 | match input { 129 | serde_json::Value::Null => self.json.push_str("null"), 130 | serde_json::Value::Bool(bool_val) => self.json.push_str(&format!("{bool_val}")), 131 | serde_json::Value::Number(num_val) => { 132 | self.json.push_str(&num_val.to_string()); 133 | } 134 | serde_json::Value::String(str_val) => self.json.push_str(&format!("\"{str_val}\"")), 135 | serde_json::Value::Array(arr_val) => { 136 | self.json.push_str("[\n"); 137 | self.current_ident += 1; 138 | for (index, item) in arr_val.iter().enumerate() { 139 | if index != 0 { 140 | self.json.push_str(",\n"); 141 | } 142 | self.ident(); 143 | self.serialize_recursive(item); 144 | } 145 | self.json.push('\n'); 146 | self.current_ident -= 1; 147 | self.ident(); 148 | self.json.push(']'); 149 | } 150 | serde_json::Value::Object(obj_val) => { 151 | self.json.push_str("{\n"); 152 | self.current_ident += 1; 153 | for (index, (key, value)) in obj_val.iter().enumerate() { 154 | if index != 0 { 155 | self.json.push_str(",\n"); 156 | } 157 | self.ident(); 158 | self.json.push_str(&format!("\"{key}\": ")); 159 | self.serialize_recursive(value); 160 | } 161 | self.json.push('\n'); 162 | self.current_ident -= 1; 163 | self.ident(); 164 | self.json.push('}'); 165 | } 166 | } 167 | 168 | let end = self.json.len(); 169 | self.spans 170 | .insert(std::ptr::from_ref::(input), start..end); 171 | } 172 | 173 | fn ident(&mut self) { 174 | self.json.push_str(&" ".repeat(self.current_ident * 4)); 175 | } 176 | 177 | fn serialized_json(&self) -> &str { 178 | &self.json 179 | } 180 | 181 | fn span(&self, val: &Value) -> Range { 182 | self.spans 183 | .get(&std::ptr::from_ref::(val)) 184 | .expect("expected span") 185 | .clone() 186 | } 187 | } 188 | 189 | #[cfg(test)] 190 | mod tests { 191 | use indoc::indoc; 192 | 193 | use super::SpanSerializer; 194 | use crate::Value; 195 | 196 | #[test] 197 | fn serializer_primitive() { 198 | let value = Value::Null; 199 | 200 | let serializer = SpanSerializer::serialize(&value); 201 | assert_eq!("null", serializer.serialized_json()); 202 | assert_eq!(0..4, serializer.span(&value)); 203 | } 204 | 205 | #[test] 206 | fn serialize_object() { 207 | let value = serde_json::json!({ 208 | "key": "value", 209 | "key_2": 2.1, 210 | }); 211 | let num_value = value.as_object().unwrap().get("key_2").unwrap(); 212 | 213 | let serializer = SpanSerializer::serialize(&value); 214 | assert_eq!( 215 | indoc! {r#" 216 | { 217 | "key": "value", 218 | "key_2": 2.1 219 | }"#}, 220 | serializer.serialized_json() 221 | ); 222 | assert_eq!(35..38, serializer.span(num_value)); 223 | } 224 | 225 | #[test] 226 | fn serialize_array() { 227 | let value = serde_json::json!([ 228 | null, 229 | true, 230 | { 231 | "key": -5, 232 | } 233 | ]); 234 | 235 | let serializer = SpanSerializer::serialize(&value); 236 | assert_eq!( 237 | indoc! {r#" 238 | [ 239 | null, 240 | true, 241 | { 242 | "key": -5 243 | } 244 | ]"#}, 245 | serializer.serialized_json() 246 | ); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | /// Assert that a json value matches its validation rules 2 | /// 3 | /// `$val` parameter can be either a string or a `assert_json::Value`. 4 | /// `validators` is the validation rule expressed as a JSON-like structure. 5 | #[macro_export] 6 | macro_rules! assert_json { 7 | ($val:expr , $($validator:tt)+) => ({ 8 | #[allow(unused_imports)] 9 | use $crate::Validator; 10 | use $crate::macros_utils::*; 11 | 12 | let validator = $crate::expand_json_validator!($($validator)+); 13 | let input = Into::::into($val).get(); 14 | let result = validator.validate(&input); 15 | if let Err(error) = result { 16 | panic!("{}", format_error(&input, &error)); 17 | } 18 | }); 19 | } 20 | 21 | /// Heavily inspired by https://github.com/serde-rs/json. 22 | /// Thanks dtolnay! 23 | #[macro_export] 24 | #[doc(hidden)] 25 | macro_rules! expand_json_validator { 26 | // ******************************************************************* 27 | // array handling 28 | // ******************************************************************* 29 | 30 | // Done with trailing comma. 31 | (@array [$($elems:expr,)*]) => { 32 | $crate::expand_json_vec_validator![$($elems,)*] 33 | }; 34 | 35 | // Done without trailing comma. 36 | (@array [$($elems:expr),*]) => { 37 | $crate::expand_json_vec_validator![$($elems),*] 38 | }; 39 | 40 | // Next element is `null`. 41 | (@array [$($elems:expr,)*] null $($rest:tt)*) => { 42 | $crate::expand_json_validator!(@array [$($elems,)* Box::new($crate::expand_json_validator!(null))] $($rest)*) 43 | }; 44 | 45 | // Next element is an array. 46 | (@array [$($elems:expr,)*] [$($array:tt)*] $($rest:tt)*) => { 47 | $crate::expand_json_validator!(@array [$($elems,)* Box::new($crate::expand_json_validator!([$($array)*]))] $($rest)*) 48 | }; 49 | 50 | // Next element is a map. 51 | (@array [$($elems:expr,)*] {$($map:tt)*} $($rest:tt)*) => { 52 | $crate::expand_json_validator!(@array [$($elems,)* Box::new($crate::expand_json_validator!({$($map)*}))] $($rest)*) 53 | }; 54 | 55 | // Next element is an expression followed by comma. 56 | (@array [$($elems:expr,)*] $next:expr, $($rest:tt)*) => { 57 | $crate::expand_json_validator!(@array [$($elems,)* $crate::expand_json_validator!($next),] $($rest)*) 58 | }; 59 | 60 | // Last element is an expression with no trailing comma. 61 | (@array [$($elems:expr,)*] $last:expr) => { 62 | $crate::expand_json_validator!(@array [$($elems,)* $crate::expand_json_validator!($last)]) 63 | }; 64 | 65 | // Comma after the most recent element. 66 | (@array [$($elems:expr),*] , $($rest:tt)*) => { 67 | $crate::expand_json_validator!(@array [$($elems,)*] $($rest)*) 68 | }; 69 | 70 | // Unexpected token after most recent element. 71 | (@array [$($elems:expr),*] $unexpected:tt $($rest:tt)*) => { 72 | $crate::json_unexpected!($unexpected) 73 | }; 74 | 75 | // ******************************************************************* 76 | // object handling 77 | // ******************************************************************* 78 | 79 | (@object $object:ident () () ()) => {}; 80 | 81 | // Insert the current entry followed by trailing comma. 82 | (@object $object:ident [$($key:tt)+] ($value:expr) , $($rest:tt)*) => { 83 | let _unused = $object.insert(($($key)+).into(), $value); 84 | $crate::expand_json_validator!(@object $object () ($($rest)*) ($($rest)*)); 85 | }; 86 | 87 | // Current entry followed by unexpected token. 88 | (@object $object:ident [$($key:tt)+] ($value:expr) $unexpected:tt $($rest:tt)*) => { 89 | $crate::json_unexpected!($unexpected); 90 | }; 91 | 92 | // Insert the last entry without trailing comma. 93 | (@object $object:ident [$($key:tt)+] ($value:expr)) => { 94 | let _unused = $object.insert(($($key)+).into(), $value); 95 | }; 96 | 97 | // Next value is `null`. 98 | (@object $object:ident ($($key:tt)+) (: null $($rest:tt)*) $copy:tt) => { 99 | $crate::expand_json_validator!(@object $object [$($key)+] (Box::new($crate::expand_json_validator!(null))) $($rest)*); 100 | }; 101 | 102 | // Next value is an array. 103 | (@object $object:ident ($($key:tt)+) (: [$($array:tt)*] $($rest:tt)*) $copy:tt) => { 104 | $crate::expand_json_validator!(@object $object [$($key)+] (Box::new($crate::expand_json_validator!([$($array)*]))) $($rest)*); 105 | }; 106 | 107 | // Next value is a map. 108 | (@object $object:ident ($($key:tt)+) (: {$($map:tt)*} $($rest:tt)*) $copy:tt) => { 109 | $crate::expand_json_validator!(@object $object [$($key)+] (Box::new($crate::expand_json_validator!({$($map)*}))) $($rest)*); 110 | }; 111 | 112 | // Next value is an expression followed by comma. 113 | (@object $object:ident ($($key:tt)+) (: $value:expr , $($rest:tt)*) $copy:tt) => { 114 | $crate::expand_json_validator!(@object $object [$($key)+] ($crate::expand_json_validator!($value)) , $($rest)*); 115 | }; 116 | 117 | // Last value is an expression with no trailing comma. 118 | (@object $object:ident ($($key:tt)+) (: $value:expr) $copy:tt) => { 119 | $crate::expand_json_validator!(@object $object [$($key)+] ($crate::expand_json_validator!($value))); 120 | }; 121 | 122 | // Missing value for last entry. Trigger a reasonable error message. 123 | (@object $object:ident ($($key:tt)+) (:) $copy:tt) => { 124 | // "unexpected end of macro invocation" 125 | $crate::expand_json_validator!(); 126 | }; 127 | 128 | // Missing colon and value for last entry. Trigger a reasonable error 129 | // message. 130 | (@object $object:ident ($($key:tt)+) () $copy:tt) => { 131 | // "unexpected end of macro invocation" 132 | $crate::expand_json_validator!(); 133 | }; 134 | 135 | // Misplaced colon. Trigger a reasonable error message. 136 | (@object $object:ident () (: $($rest:tt)*) ($colon:tt $($copy:tt)*)) => { 137 | // Takes no arguments so "no rules expected the token `:`". 138 | $crate::json_unexpected!($colon); 139 | }; 140 | 141 | // Found a comma inside a key. Trigger a reasonable error message. 142 | (@object $object:ident ($($key:tt)*) (, $($rest:tt)*) ($comma:tt $($copy:tt)*)) => { 143 | // Takes no arguments so "no rules expected the token `,`". 144 | $crate::json_unexpected!($comma); 145 | }; 146 | 147 | // Key is fully parenthesized. This avoids clippy double_parens false 148 | // positives because the parenthesization may be necessary here. 149 | (@object $object:ident () (($key:expr) : $($rest:tt)*) $copy:tt) => { 150 | $crate::expand_json_validator!(@object $object ($key) (: $($rest)*) (: $($rest)*)); 151 | }; 152 | 153 | // Refuse to absorb colon token into key expression. 154 | (@object $object:ident ($($key:tt)*) (: $($unexpected:tt)+) $copy:tt) => { 155 | $crate::json_expect_expr_comma!($($unexpected)+); 156 | }; 157 | 158 | // Munch a token into the current key. 159 | (@object $object:ident ($($key:tt)*) ($tt:tt $($rest:tt)*) $copy:tt) => { 160 | $crate::expand_json_validator!(@object $object ($($key)* $tt) ($($rest)*) ($($rest)*)); 161 | }; 162 | 163 | // ******************************************************************* 164 | // primitive handling 165 | // ******************************************************************* 166 | 167 | (null) => { 168 | $crate::validators::null() 169 | }; 170 | 171 | ([]) => { 172 | $crate::validators::array_empty() 173 | }; 174 | 175 | ([ $($tt:tt)+ ]) => { 176 | // { 177 | // let mut validators_array = vec![]; 178 | // } 179 | $crate::validators::array($crate::expand_json_validator!(@array [] $($tt)+)) 180 | // $crate::Value::Array(json_internal!(@array [] $($tt)+)) 181 | }; 182 | 183 | ({}) => { 184 | $crate::validators::object(std::collections::HashMap::new()) 185 | }; 186 | 187 | ({ $($tt:tt)+ }) => { 188 | $crate::validators::object({ 189 | let mut object: std::collections::HashMap> = std::collections::HashMap::new(); 190 | $crate::expand_json_validator!(@object object () ($($tt)+) ($($tt)+)); 191 | object 192 | }) 193 | }; 194 | 195 | ($other:expr) => { 196 | { 197 | let validator: ValidatorInput = $other.into(); 198 | validator.get() 199 | } 200 | }; 201 | } 202 | 203 | // The expand_json_validator macro above cannot invoke vec directly because it uses 204 | // local_inner_macros. A vec invocation there would resolve to $crate::vec. 205 | // Instead invoke vec here outside of local_inner_macros. 206 | #[macro_export] 207 | #[doc(hidden)] 208 | macro_rules! expand_json_vec_validator { 209 | ($($content:tt)*) => { 210 | vec![$($content)*] 211 | }; 212 | } 213 | 214 | #[macro_export] 215 | #[doc(hidden)] 216 | macro_rules! json_unexpected { 217 | () => {}; 218 | } 219 | 220 | #[macro_export] 221 | #[doc(hidden)] 222 | macro_rules! json_expect_expr_comma { 223 | ($e:expr , $($tt:tt)*) => {}; 224 | } 225 | 226 | #[cfg(test)] 227 | mod test { 228 | #[test] 229 | fn assert_json_with_serde_input() { 230 | assert_json!(serde_json::json!("hello"), "hello"); 231 | } 232 | 233 | #[test] 234 | fn assert_json_null() { 235 | assert_json!("null", null); 236 | } 237 | 238 | #[test] 239 | fn assert_json_number() { 240 | assert_json!("23", 23); 241 | assert_json!("2.3", 2.3); 242 | } 243 | 244 | #[test] 245 | fn assert_json_bool() { 246 | assert_json!("true", true); 247 | assert_json!("false", false); 248 | } 249 | 250 | #[test] 251 | fn assert_json_string() { 252 | assert_json!(r#""str""#, "str"); 253 | } 254 | 255 | #[test] 256 | fn assert_json_object_empty() { 257 | assert_json!("{}", {}); 258 | } 259 | 260 | #[test] 261 | fn assert_json_object() { 262 | assert_json!(r#"{ 263 | "null": null, 264 | "bool_true": true, 265 | "bool_false": false, 266 | "num_int": -6, 267 | "num_float": 2.4, 268 | "str": "test", 269 | "inner_obj": { 270 | "test": "hello" 271 | }, 272 | "inner_empty_obj": {}, 273 | "inner_array": [1, 3], 274 | "inner_empty_arr": [] 275 | }"#, 276 | { 277 | "null": null, 278 | "bool_true": true, 279 | "bool_false": false, 280 | "num_int": -6, 281 | "num_float": 2.4, 282 | "str": "test", 283 | "inner_obj": { 284 | "test": "hello" 285 | }, 286 | "inner_empty_obj": {}, 287 | "inner_array": [1, 3], 288 | "inner_empty_arr": [], 289 | } 290 | ); 291 | } 292 | 293 | #[test] 294 | fn assert_json_array_empty() { 295 | assert_json!("[]", []); 296 | } 297 | 298 | #[test] 299 | #[should_panic] 300 | fn assert_json_array_empty_err() { 301 | assert_json!("[null]", []); 302 | } 303 | 304 | #[test] 305 | fn assert_json_array() { 306 | assert_json!( 307 | r#"[ 308 | null, 309 | true, 310 | false, 311 | 8, 312 | 8.9, 313 | "str", 314 | { "key": null }, 315 | {}, 316 | [false, "hello"], 317 | [] 318 | ]"#, 319 | [ 320 | null, 321 | true, 322 | false, 323 | 8, 324 | 8.9, 325 | "str", 326 | { "key": null }, 327 | {}, 328 | [false, "hello"], 329 | [] 330 | ] 331 | ); 332 | } 333 | 334 | #[test] 335 | fn assert_json_custom_validator() { 336 | assert_json!("null", crate::validators::any()); 337 | } 338 | 339 | #[test] 340 | fn assert_json_validator_with_and() { 341 | assert_json!( 342 | r#""test""#, 343 | crate::validators::any().and(crate::validators::eq(String::from("test"))) 344 | ); 345 | } 346 | 347 | #[test] 348 | #[should_panic] 349 | fn assert_json_null_not_valid() { 350 | assert_json!("null", true); 351 | } 352 | 353 | #[test] 354 | #[expect( 355 | clippy::semicolon_if_nothing_returned, 356 | reason = "the missing `;` is normal this is to test if the the assert_json macros can be used as an expression like assert_eq!" 357 | )] 358 | fn assert_json_is_expression() { 359 | assert_json!("null", null) 360 | } 361 | 362 | #[test] 363 | fn assert_json_with_variable() { 364 | let num = 5; 365 | assert_json!("5", num); 366 | } 367 | } 368 | --------------------------------------------------------------------------------