hurl-7.1.0/.cargo_vcs_info.json0000644000000001530000000000100120120ustar { "git": { "sha1": "77798424906a431e5b1c136f540d6cb5ed79b788" }, "path_in_vcs": "packages/hurl" }hurl-7.1.0/Cargo.lock0000644000001216050000000000100077730ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "adler32" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" [[package]] name = "aho-corasick" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "alloc-no-stdlib" version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" dependencies = [ "alloc-no-stdlib", ] [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anstream" version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", "windows-sys 0.61.2", ] [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bindgen" version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags", "cexpr", "clang-sys", "itertools", "proc-macro2", "quote", "regex", "rustc-hash", "shlex", "syn", ] [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[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 = "brotli" version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", "brotli-decompressor", ] [[package]] name = "brotli-decompressor" version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] [[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "cc" version = "1.2.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" dependencies = [ "find-msvc-tools", "shlex", ] [[package]] name = "cexpr" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ "nom", ] [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", "num-traits", "windows-link", ] [[package]] name = "clang-sys" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", "libloading", ] [[package]] name = "clap" version = "4.5.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa8120877db0e5c011242f96806ce3c94e0737ab8108532a76a3300a01db2ab8" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" version = "4.5.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02576b399397b659c26064fbc92a75fede9d18ffd5f80ca1cd74ddab167016e1" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", "terminal_size", ] [[package]] name = "clap_lex" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "colored" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core2" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" dependencies = [ "memchr", ] [[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crc32fast" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", ] [[package]] name = "curl" version = "0.4.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc" dependencies = [ "curl-sys", "libc", "openssl-probe", "openssl-sys", "schannel", "socket2", "windows-sys 0.59.0", ] [[package]] name = "curl-sys" version = "0.4.84+curl-8.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abc4294dc41b882eaff37973c2ec3ae203d0091341ee68fbadd1d06e0c18a73b" dependencies = [ "cc", "libc", "libz-sys", "openssl-sys", "pkg-config", "vcpkg", "windows-sys 0.59.0", ] [[package]] name = "dary_heap" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" [[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 = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[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 = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", "wasip2", ] [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] [[package]] name = "hurl" version = "7.1.0" dependencies = [ "base64", "brotli", "cc", "chrono", "clap", "curl", "curl-sys", "encoding_rs", "glob", "hurl_core", "libflate", "libxml", "md5", "percent-encoding", "regex", "serde", "serde_json", "sha2", "similar", "terminal_size", "termion", "url", "uuid", "winres", "xml-rs", ] [[package]] name = "hurl_core" version = "7.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd2d82d2ed5224626e5a7c309efd6cdaffae1356ec1054f83dd3cfcd1dbc1ed8" dependencies = [ "colored", "libxml", "regex", ] [[package]] name = "iana-time-zone" version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "icu_collections" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locale_core" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_normalizer" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec", ] [[package]] name = "idna" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", "utf8_iter", ] [[package]] name = "idna_adapter" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] name = "libc" version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libflate" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74" dependencies = [ "adler32", "core2", "crc32fast", "dary_heap", "libflate_lz77", ] [[package]] name = "libflate_lz77" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" dependencies = [ "core2", "hashbrown", "rle-decode-fast", ] [[package]] name = "libloading" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", "windows-link", ] [[package]] name = "libredox" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags", "libc", "redox_syscall", ] [[package]] name = "libxml" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74a5e46b8bcd6b70cb485ca086e43aa020af841e29fb0aba88ce02cd1cb52cc7" dependencies = [ "bindgen", "libc", "pkg-config", "vcpkg", ] [[package]] name = "libz-sys" version = "1.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" dependencies = [ "cc", "libc", "pkg-config", "vcpkg", ] [[package]] name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "log" version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "md5" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", "minimal-lexical", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "numtoa" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" version = "300.5.4+3.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" dependencies = [ "cc", ] [[package]] name = "openssl-sys" version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", "openssl-src", "pkg-config", "vcpkg", ] [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "potential_utf" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "proc-macro2" version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core", ] [[package]] name = "rand_core" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ "getrandom", ] [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] [[package]] name = "redox_termios" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" [[package]] name = "regex" version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rle-decode-fast" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "schannel" version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", ] [[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", "serde_core", ] [[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", "windows-sys 0.60.2", ] [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" version = "2.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "synstructure" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "terminal_size" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ "rustix", "windows-sys 0.60.2", ] [[package]] name = "termion" version = "4.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3669a69de26799d6321a5aa713f55f7e2cd37bd47be044b50f2acafc42c122bb" dependencies = [ "libc", "libredox", "numtoa", "redox_termios", ] [[package]] name = "tinystr" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", ] [[package]] name = "toml" version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" dependencies = [ "serde", ] [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "url" version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", ] [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom", "js-sys", "rand", "wasm-bindgen", ] [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ "bumpalo", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "windows-interface" version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ "windows-targets 0.53.5", ] [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows-targets" version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", "windows_x86_64_msvc 0.53.1", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winres" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" dependencies = [ "toml", ] [[package]] name = "wit-bindgen" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "xml-rs" version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "yoke" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zerocopy" version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zerotrie" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", "zerofrom", ] [[package]] name = "zerovec" version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", "syn", ] hurl-7.1.0/Cargo.toml0000644000000052620000000000100100160ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" rust-version = "1.91.1" name = "hurl" version = "7.1.0" authors = [ "Fabrice Reix ", "Jean-Christophe Amiel ", "Filipe Pinto ", ] build = "build.rs" autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "Hurl, run and test HTTP requests" homepage = "https://hurl.dev" documentation = "https://hurl.dev" readme = "README.md" license = "Apache-2.0" repository = "https://github.com/Orange-OpenSource/hurl" [features] static-openssl = [ "curl/static-ssl", "curl-sys/static-ssl", ] [lib] name = "hurl" path = "src/lib.rs" [[bin]] name = "hurl" path = "src/main.rs" [[test]] name = "sample" path = "tests/sample.rs" [dependencies.base64] version = "0.22.1" [dependencies.brotli] version = "8.0.2" [dependencies.chrono] version = "0.4.42" features = ["clock"] default-features = false [dependencies.clap] version = "4.5.51" features = [ "string", "wrap_help", ] [dependencies.curl] version = "0.4.49" [dependencies.curl-sys] version = "0.4.84" [dependencies.encoding_rs] version = "0.8.35" [dependencies.glob] version = "0.3.3" [dependencies.hurl_core] version = "7.1.0" [dependencies.libflate] version = "2.2.1" [dependencies.libxml] version = "0.3.8" [dependencies.md5] version = "0.7.0" [dependencies.percent-encoding] version = "2.3.2" [dependencies.regex] version = "1.12.2" [dependencies.serde] version = "1.0.228" features = ["derive"] [dependencies.serde_json] version = "1.0.145" features = ["arbitrary_precision"] [dependencies.sha2] version = "0.10.9" [dependencies.similar] version = "2.7.0" [dependencies.terminal_size] version = "0.4.3" [dependencies.url] version = "2.5.7" [dependencies.uuid] version = "1.18.1" features = [ "v4", "fast-rng", ] [dependencies.xml-rs] version = "0.8.28" [build-dependencies.cc] version = "1.2.46" [target."cfg(unix)".dependencies.termion] version = "4.0.5" [target."cfg(windows)".build-dependencies.winres] version = "0.1.12" [lints.clippy] empty_structs_with_brackets = "deny" manual_string_new = "deny" semicolon_if_nothing_returned = "deny" wildcard-imports = "deny" [lints.rust] warnings = "deny" hurl-7.1.0/Cargo.toml.orig000064400000000000000000000031641046102023000134760ustar 00000000000000[package] name = "hurl" version = "7.1.0" authors = ["Fabrice Reix ", "Jean-Christophe Amiel ", "Filipe Pinto "] edition = "2021" license = "Apache-2.0" description = "Hurl, run and test HTTP requests" documentation = "https://hurl.dev" homepage = "https://hurl.dev" repository = "https://github.com/Orange-OpenSource/hurl" rust-version = "1.91.1" [lib] name = "hurl" [features] # Re-export of curl/static-ssl: use a bundled OpenSSL version and statically link to it. Only applies on platforms that # use OpenSSL static-openssl = ["curl/static-ssl", "curl-sys/static-ssl"] [dependencies] base64 = "0.22.1" brotli = "8.0.2" chrono = { version = "0.4.42", default-features = false, features = ["clock"] } clap = { version = "4.5.51", features = ["string", "wrap_help"] } curl = "0.4.49" curl-sys = "0.4.84" encoding_rs = "0.8.35" glob = "0.3.3" hurl_core = { version = "7.1.0", path = "../hurl_core" } libflate = "2.2.1" libxml = "0.3.8" md5 = "0.7.0" percent-encoding = "2.3.2" regex = "1.12.2" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.145", features = ["arbitrary_precision"] } sha2 = "0.10.9" url = "2.5.7" xml-rs = { version = "0.8.28" } # uuid features: lets you generate random UUIDs and use a faster (but still sufficiently random) RNG uuid = { version = "1.18.1", features = ["v4" , "fast-rng"] } similar = "2.7.0" terminal_size = "0.4.3" [target.'cfg(unix)'.dependencies] termion = "4.0.5" [target.'cfg(windows)'.build-dependencies] winres = "0.1.12" [build-dependencies] cc = "1.2.46" [lints] workspace = true hurl-7.1.0/README.md000064400000000000000000002440721046102023000120730ustar 00000000000000 Hurl Logo [![deploy status](https://github.com/Orange-OpenSource/hurl/workflows/test/badge.svg)](https://github.com/Orange-OpenSource/hurl/actions) [![coverage](https://Orange-OpenSource.github.io/hurl/coverage/badges/flat.svg)](https://Orange-OpenSource.github.io/hurl/coverage) [![Crates.io](https://img.shields.io/crates/v/hurl.svg)](https://crates.io/crates/hurl) [![documentation](https://img.shields.io/badge/-documentation-ff0288)](https://hurl.dev) # What's Hurl? Hurl is a command line tool that runs HTTP requests defined in a simple plain text format. It can chain requests, capture values and evaluate queries on headers and body response. Hurl is very versatile: it can be used for both fetching data and testing HTTP sessions. Hurl makes it easy to work with HTML content, REST / SOAP / GraphQL APIs, or any other XML / JSON based APIs. ```hurl # Go home and capture token GET https://example.org HTTP 200 [Captures] csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)" # Do login! POST https://example.org/login X-CSRF-TOKEN: {{csrf_token}} [Form] user: toto password: 1234 HTTP 302 ``` Chaining multiple requests is easy: ```hurl GET https://example.org/api/health GET https://example.org/api/step1 GET https://example.org/api/step2 GET https://example.org/api/step3 ``` # Also an HTTP Test Tool Hurl can run HTTP requests but can also be used to test HTTP responses. Different types of queries and predicates are supported, from [XPath] and [JSONPath] on body response, to assert on status code and response headers. Hurl Demo It is well adapted for REST / JSON APIs ```hurl POST https://example.org/api/tests { "id": "4568", "evaluate": true } HTTP 200 [Asserts] header "X-Frame-Options" == "SAMEORIGIN" jsonpath "$.status" == "RUNNING" # Check the status code jsonpath "$.tests" count == 25 # Check the number of items jsonpath "$.id" matches /\d{4}/ # Check the format of the id ``` HTML content ```hurl GET https://example.org HTTP 200 [Asserts] xpath "normalize-space(//head/title)" == "Hello world!" ``` GraphQL ~~~hurl POST https://example.org/graphql ```graphql { human(id: "1000") { name height(unit: FOOT) } } ``` HTTP 200 ~~~ and even SOAP APIs ```hurl POST https://example.org/InStock Content-Type: application/soap+xml; charset=utf-8 SOAPAction: "http://www.w3.org/2003/05/soap-envelope" GOOG HTTP 200 ``` Hurl can also be used to test the performance of HTTP endpoints ```hurl GET https://example.org/api/v1/pets HTTP 200 [Asserts] duration < 1000 # Duration in ms ``` And check response bytes ```hurl GET https://example.org/data.tar.gz HTTP 200 [Asserts] sha256 == hex,039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81; ``` Finally, Hurl is easy to integrate in CI/CD, with text, JUnit, TAP and HTML reports HTML report # Why Hurl?
  • Text Format: for both devops and developers
  • Fast CLI: a command line for local dev and continuous integration
  • Single Binary: easy to install, with no runtime required
# Powered by curl Hurl is a lightweight binary written in [Rust]. Under the hood, Hurl HTTP engine is powered by [libcurl], one of the most powerful and reliable file transfer libraries. With its text file format, Hurl adds syntactic sugar to run and test HTTP requests, but it's still the [curl] that we love: __fast__, __efficient__ and __IPv6 / HTTP/3 ready__. # Feedbacks To support its development, [star Hurl on GitHub]! [Feedback, suggestion, bugs or improvements] are welcome. ```hurl POST https://hurl.dev/api/feedback { "name": "John Doe", "feedback": "Hurl is awesome!" } HTTP 200 ``` # Resources [License] [Blog] [Tutorial] [Documentation] (download [HTML], [PDF], [Markdown]) [GitHub] # Table of Contents * [Samples](#samples) * [Getting Data](#getting-data) * [HTTP Headers](#http-headers) * [Query Params](#query-params) * [Basic Authentication](#basic-authentication) * [Passing Data between Requests ](#passing-data-between-requests) * [Sending Data](#sending-data) * [Sending HTML Form Data](#sending-html-form-data) * [Sending Multipart Form Data](#sending-multipart-form-data) * [Posting a JSON Body](#posting-a-json-body) * [Templating a JSON Body](#templating-a-json-body) * [Templating a XML Body](#templating-a-xml-body) * [Using GraphQL Query](#using-graphql-query) * [Using Dynamic Datas](#using-dynamic-datas) * [Testing Response](#testing-response) * [Testing Status Code](#testing-status-code) * [Testing Response Headers](#testing-response-headers) * [Testing REST APIs](#testing-rest-apis) * [Testing HTML Response](#testing-html-response) * [Testing Set-Cookie Attributes](#testing-set-cookie-attributes) * [Testing Bytes Content](#testing-bytes-content) * [SSL Certificate](#ssl-certificate) * [Checking Full Body](#checking-full-body) * [Testing Redirections](#testing-redirections) * [Debug Tips](#debug-tips) * [Verbose Mode](#verbose-mode) * [Error Format](#error-format) * [Output Response Body](#output-response-body) * [Export curl Commands](#export-curl-commands) * [Using Proxy](#using-proxy) * [Reports](#reports) * [HTML Report](#html-report) * [JSON Report](#json-report) * [JUnit Report](#junit-report) * [TAP Report](#tap-report) * [JSON Output](#json-output) * [Others](#others) * [HTTP Version](#http-version) * [IP Address](#ip-address) * [Polling and Retry](#polling-and-retry) * [Delaying Requests](#delaying-requests) * [Skipping Requests](#skipping-requests) * [Testing Endpoint Performance](#testing-endpoint-performance) * [Using SOAP APIs](#using-soap-apis) * [Capturing and Using a CSRF Token](#capturing-and-using-a-csrf-token) * [Redacting Secrets](#redacting-secrets) * [Checking Byte Order Mark (BOM) in Response Body](#checking-byte-order-mark-bom-in-response-body) * [AWS Signature Version 4 Requests](#aws-signature-version-4-requests) * [Using curl Options](#using-curl-options) * [Manual](#manual) * [Name](#name) * [Synopsis](#synopsis) * [Description](#description) * [Hurl File Format](#hurl-file-format) * [Capturing values](#capturing-values) * [Asserts](#asserts) * [Options](#options) * [Environment](#environment) * [Exit Codes](#exit-codes) * [WWW](#www) * [See Also](#see-also) * [Installation](#installation) * [Binaries Installation](#binaries-installation) * [Linux](#linux) * [Debian / Ubuntu](#debian--ubuntu) * [Alpine](#alpine) * [Arch Linux / Manjaro](#arch-linux--manjaro) * [NixOS / Nix](#nixos--nix) * [macOS](#macos) * [Homebrew](#homebrew) * [MacPorts](#macports) * [FreeBSD](#freebsd) * [Windows](#windows) * [Zip File](#zip-file) * [Installer](#installer) * [Chocolatey](#chocolatey) * [Scoop](#scoop) * [Windows Package Manager](#windows-package-manager) * [Cargo](#cargo) * [conda-forge](#conda-forge) * [Docker](#docker) * [npm](#npm) * [Building From Sources](#building-from-sources) * [Build on Linux](#build-on-linux) * [Debian based distributions](#debian-based-distributions) * [Fedora based distributions](#fedora-based-distributions) * [Red Hat based distributions](#red-hat-based-distributions) * [Arch based distributions](#arch-based-distributions) * [Alpine based distributions](#alpine-based-distributions) * [Build on macOS](#build-on-macos) * [Build on Windows](#build-on-windows) # Samples To run a sample, edit a file with the sample content, and run Hurl: ```shell $ vi sample.hurl GET https://example.org $ hurl sample.hurl ``` By default, Hurl behaves like [curl] and outputs the last HTTP response's [entry]. To have a test oriented output, you can use [`--test` option]: ```shell $ hurl --test sample.hurl ``` A particular response can be saved with [`[Options] section`](https://hurl.dev/docs/request.html#options): ```hurl GET https://example.ord/cats/123 [Options] output: cat123.txt # use - to output to stdout HTTP 200 GET https://example.ord/dogs/567 HTTP 200 ``` Finally, Hurl can take files as input, or directories. In the latter case, Hurl will search files with `.hurl` extension recursively. ```shell $ hurl --test integration/*.hurl $ hurl --test . ``` You can check [Hurl tests suite] for more samples. ## Getting Data A simple GET: ```hurl GET https://example.org ``` Requests can be chained: ```hurl GET https://example.org/a GET https://example.org/b HEAD https://example.org/c GET https://example.org/c ``` [Doc](https://hurl.dev/docs/request.html#method) ### HTTP Headers A simple GET with headers: ```hurl GET https://example.org/news User-Agent: Mozilla/5.0 Accept: */* Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Connection: keep-alive ``` [Doc](https://hurl.dev/docs/request.html#headers) ### Query Params ```hurl GET https://example.org/news [Query] order: newest search: something to search count: 100 ``` Or: ```hurl GET https://example.org/news?order=newest&search=something%20to%20search&count=100 ``` > With `[Query]` section, params don't need to be URL escaped. [Doc](https://hurl.dev/docs/request.html#query-parameters) ### Basic Authentication ```hurl GET https://example.org/protected [BasicAuth] bob: secret ``` [Doc](https://hurl.dev/docs/request.html#basic-authentication) This is equivalent to construct the request with a [Authorization] header: ```hurl # Authorization header value can be computed with `echo -n 'bob:secret' | base64` GET https://example.org/protected Authorization: Basic Ym9iOnNlY3JldA== ``` Basic authentication section allows per request authentication. If you want to add basic authentication to all the requests of a Hurl file you could use [`-u/--user` option]: ```shell $ hurl --user bob:secret login.hurl ``` [`--user`] option can also be set per request: ```hurl GET https://example.org/login [Options] user: bob:secret HTTP 200 GET https://example.org/login [Options] user: alice:secret HTTP 200 ``` ### Passing Data between Requests [Captures] can be used to pass data from one request to another: ```hurl POST https://sample.org/orders HTTP 201 [Captures] order_id: jsonpath "$.order.id" GET https://sample.org/orders/{{order_id}} HTTP 200 ``` [Doc](https://hurl.dev/docs/capturing-response.html) ## Sending Data ### Sending HTML Form Data ```hurl POST https://example.org/contact [Form] default: false token: {{token}} email: john.doe@rookie.org number: 33611223344 ``` [Doc](https://hurl.dev/docs/request.html#form-parameters) ### Sending Multipart Form Data ```hurl POST https://example.org/upload [Multipart] field1: value1 field2: file,example.txt; # One can specify the file content type: field3: file,example.zip; application/zip ``` [Doc](https://hurl.dev/docs/request.html#multipart-form-data) Multipart forms can also be sent with a [multiline string body]: ~~~hurl POST https://example.org/upload Content-Type: multipart/form-data; boundary="boundary" ``` --boundary Content-Disposition: form-data; name="key1" value1 --boundary Content-Disposition: form-data; name="upload1"; filename="data.txt" Content-Type: text/plain Hello World! --boundary Content-Disposition: form-data; name="upload2"; filename="data.html" Content-Type: text/html
Hello World!
--boundary-- ``` ~~~ In that case, files have to be inlined in the Hurl file. [Doc](https://hurl.dev/docs/request.html#multiline-string-body) ### Posting a JSON Body With an inline JSON: ```hurl POST https://example.org/api/tests { "id": "456", "evaluate": true } ``` [Doc](https://hurl.dev/docs/request.html#json-body) With a local file: ```hurl POST https://example.org/api/tests Content-Type: application/json file,data.json; ``` [Doc](https://hurl.dev/docs/request.html#file-body) ### Templating a JSON Body ```hurl PUT https://example.org/api/hits Content-Type: application/json { "key0": "{{a_string}}", "key1": {{a_bool}}, "key2": {{a_null}}, "key3": {{a_number}} } ``` Variables can be initialized via command line: ```shell $ hurl --variable a_string=apple \ --variable a_bool=true \ --variable a_null=null \ --variable a_number=42 \ test.hurl ``` Resulting in a PUT request with the following JSON body: ``` { "key0": "apple", "key1": true, "key2": null, "key3": 42 } ``` [Doc](https://hurl.dev/docs/templates.html) ### Templating a XML Body Using templates with [XML body] is not currently supported in Hurl. You can use templates in [XML multiline string body] with variables to send a variable XML body: ~~~hurl POST https://example.org/echo/post/xml ```xml {{login}} {{password}} ``` ~~~ [Doc](https://hurl.dev/docs/request.html#multiline-string-body) ### Using GraphQL Query A simple GraphQL query: ~~~hurl POST https://example.org/starwars/graphql ```graphql { human(id: "1000") { name height(unit: FOOT) } } ``` ~~~ A GraphQL query with variables: ~~~hurl POST https://example.org/starwars/graphql ```graphql query Hero($episode: Episode, $withFriends: Boolean!) { hero(episode: $episode) { name friends @include(if: $withFriends) { name } } } variables { "episode": "JEDI", "withFriends": false } ``` ~~~ GraphQL queries can also use [Hurl templates]. [Doc](https://hurl.dev/docs/request.html#graphql-body) ### Using Dynamic Datas [Functions] like `newUuid` and `newDate` can be used in templates to create dynamic datas: A file that creates a dynamic email (i.e `0531f78f-7f87-44be-a7f2-969a1c4e6d97@test.com`): ```hurl POST https://example.org/api/foo { "name": "foo", "email": "{{newUuid}}@test.com" } ``` A file that creates a dynamic query parameter (i.e `2024-12-02T10:35:44.461731Z`): ```hurl GET https://example.org/api/foo [Query] date: {{newDate}} HTTP 200 ``` [Doc](https://hurl.dev/docs/templates.html#functions) ## Testing Response Responses are optional, everything after `HTTP` is part of the response asserts. ```hurl # A request with (almost) no check: GET https://foo.com # A status code check: GET https://foo.com HTTP 200 # A test on response body GET https://foo.com HTTP 200 [Asserts] jsonpath "$.state" == "running" ``` ### Testing Status Code ```hurl GET https://example.org/order/435 HTTP 200 ``` [Doc](https://hurl.dev/docs/asserting-response.html#version-status) ```hurl GET https://example.org/order/435 # Testing status code is in a 200-300 range HTTP * [Asserts] status >= 200 status < 300 ``` [Doc](https://hurl.dev/docs/asserting-response.html#status-assert) ### Testing Response Headers Use implicit response asserts to test header values: ```hurl GET https://example.org/index.html HTTP 200 Set-Cookie: theme=light Set-Cookie: sessionToken=abc123; Expires=Wed, 09 Jun 2021 10:18:14 GMT ``` [Doc](https://hurl.dev/docs/asserting-response.html#headers) Or use explicit response asserts with [predicates]: ```hurl GET https://example.org HTTP 302 [Asserts] header "Location" contains "www.example.net" ``` [Doc](https://hurl.dev/docs/asserting-response.html#header-assert) Implicit and explicit asserts can be combined: ```hurl GET https://example.org/index.html HTTP 200 Set-Cookie: theme=light Set-Cookie: sessionToken=abc123; Expires=Wed, 09 Jun 2021 10:18:14 GMT [Asserts] header "Location" contains "www.example.net" ``` ### Testing REST APIs Asserting JSON body response (node values, collection count etc...) with [JSONPath]: ```hurl GET https://example.org/order screencapability: low HTTP 200 [Asserts] jsonpath "$.validated" == true jsonpath "$.userInfo" isObject jsonpath "$.userInfo.firstName" == "Franck" jsonpath "$.userInfo.lastName" == "Herbert" jsonpath "$.hasDevice" == false jsonpath "$.links" count == 12 jsonpath "$.state" != null jsonpath "$.order" matches "^order-\\d{8}$" jsonpath "$.order" matches /^order-\d{8}$/ # Alternative syntax with regex literal jsonpath "$.id" matches /(?i)[a-z]*/ # See syntax for flags jsonpath "$.created" isIsoDate ``` [Doc](https://hurl.dev/docs/asserting-response.html#jsonpath-assert) ### Testing HTML Response ```hurl GET https://example.org HTTP 200 Content-Type: text/html; charset=UTF-8 [Asserts] xpath "string(/html/head/title)" contains "Example" # Check title xpath "count(//p)" == 2 # Check the number of p xpath "//p" count == 2 # Similar assert for p xpath "boolean(count(//h2))" == false # Check there is no h2 xpath "//h2" not exists # Similar assert for h2 xpath "string(//div[1])" matches /Hello.*/ ``` [Doc](https://hurl.dev/docs/asserting-response.html#xpath-assert) ### Testing Set-Cookie Attributes ```hurl GET https://example.org/home HTTP 200 [Asserts] cookie "JSESSIONID" == "8400BAFE2F66443613DC38AE3D9D6239" cookie "JSESSIONID[Value]" == "8400BAFE2F66443613DC38AE3D9D6239" cookie "JSESSIONID[Expires]" contains "Wed, 13 Jan 2021" cookie "JSESSIONID[Secure]" exists cookie "JSESSIONID[HttpOnly]" exists cookie "JSESSIONID[SameSite]" == "Lax" ``` [Doc](https://hurl.dev/docs/asserting-response.html#cookie-assert) ### Testing Bytes Content Check the SHA-256 response body hash: ```hurl GET https://example.org/data.tar.gz HTTP 200 [Asserts] sha256 == hex,039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81; ``` [Doc](https://hurl.dev/docs/asserting-response.html#sha-256-assert) ### SSL Certificate Check the properties of a SSL certificate: ```hurl GET https://example.org HTTP 200 [Asserts] certificate "Subject" == "CN=example.org" certificate "Issuer" == "C=US, O=Let's Encrypt, CN=R3" certificate "Expire-Date" daysAfterNow > 15 certificate "Serial-Number" matches /[\da-f]+/ ``` [Doc](https://hurl.dev/docs/asserting-response.html#ssl-certificate-assert) ### Checking Full Body Use implicit body to test an exact JSON body match: ```hurl GET https://example.org/api/cats/123 HTTP 200 { "name" : "Purrsloud", "species" : "Cat", "favFoods" : ["wet food", "dry food", "any food"], "birthYear" : 2016, "photo" : "https://learnwebcode.github.io/json-example/images/cat-2.jpg" } ``` [Doc](https://hurl.dev/docs/asserting-response.html#json-body) Or an explicit assert file: ```hurl GET https://example.org/index.html HTTP 200 [Asserts] body == file,cat.json; ``` [Doc](https://hurl.dev/docs/asserting-response.html#body-assert) Implicit asserts supports XML body: ```hurl GET https://example.org/api/catalog HTTP 200 Gambardella, Matthew XML Developer's Guide Computer 44.95 2000-10-01 An in-depth look at creating applications with XML. ``` [Doc](https://hurl.dev/docs/asserting-response.html#xml-body) Plain text: ~~~hurl GET https://example.org/models HTTP 200 ``` Year,Make,Model,Description,Price 1997,Ford,E350,"ac, abs, moon",3000.00 1999,Chevy,"Venture ""Extended Edition""","",4900.00 1999,Chevy,"Venture ""Extended Edition, Very Large""",,5000.00 1996,Jeep,Grand Cherokee,"MUST SELL! air, moon roof, loaded",4799.00 ``` ~~~ [Doc](https://hurl.dev/docs/asserting-response.html#multiline-string-body) One line: ```hurl POST https://example.org/helloworld HTTP 200 `Hello world!` ``` [Doc](https://hurl.dev/docs/asserting-response.html#oneline-string-body) File: ```hurl GET https://example.org HTTP 200 file,data.bin; ``` [Doc](https://hurl.dev/docs/asserting-response.html#file-body) ### Testing Redirections By default, Hurl doesn't follow redirection so each step of a redirect must be run manually and can be analysed: ```hurl GET https://example.org/step1 HTTP 301 [Asserts] header "Location" == "https://example.org/step2" GET https://example.org/step2 HTTP 301 [Asserts] header "Location" == "https://example.org/step3" GET https://example.org/step3 HTTP 200 ``` [Doc](https://hurl.dev/docs/asserting-response.html) Using [`--location`] and [`--location-trusted`] (either with command line option or per request), Hurl follows redirection and each step of the redirection can be checked. ```hurl GET https://example.org/step1 [Options] location: true HTTP 200 [Asserts] redirects count == 2 redirects nth 0 location == "https://example.org/step2" redirects nth 1 location == "https://example.org/step3" ``` ```hurl GET https://example.org/step1 [Options] location-trusted: true HTTP 200 [Asserts] redirects last location == "https://example.org/step2" ``` [Doc](https://hurl.dev/docs/asserting-response.html#redirects-assert) ## Debug Tips ### Verbose Mode To get more info on a given request/response, use [`[Options]` section](https://hurl.dev/docs/request.html#options): ```hurl GET https://example.org HTTP 200 GET https://example.org/api/cats/123 [Options] very-verbose: true HTTP 200 ``` `--verbose` and `--very-verbose` can be also used globally as command line options. [Doc](https://hurl.dev/docs/manual.html#very-verbose) ### Error Format ```shell $ hurl --test --error-format long *.hurl ``` [Doc](https://hurl.dev/docs/manual.html#error-format) ### Output Response Body Use `--output` on a specific request to get the response body (`-` can be used as standard output): ```hurl GET https://foo.com/failure [Options] # use - to output on standard output, foo.bin to save on disk output: - HTTP 200 GET https://foo.com/success HTTP 200 ``` [Doc](https://hurl.dev/docs/manual.html#output) ### Export curl Commands ```shell $ hurl ---curl /tmp/curl.txt *.hurl ``` [Doc](https://hurl.dev/docs/manual.html#curl) ### Using Proxy Use `--proxy` on a specific request or globally as command line option: ```hurl GET https://foo.com/a HTTP 200 GET https://foo.com/b [Options] proxy: localhost:8888 HTTP 200 GET https://foo.com/c HTTP 200 ``` ## Reports ### HTML Report ```shell $ hurl --test --report-html build/report/ *.hurl ``` [Doc](https://hurl.dev/docs/running-tests.html#generating-report) ### JSON Report ```shell $ hurl --test --report-json build/report/ *.hurl ``` [Doc](https://hurl.dev/docs/running-tests.html#generating-report) ### JUnit Report ```shell $ hurl --test --report-junit build/report.xml *.hurl ``` [Doc](https://hurl.dev/docs/running-tests.html#generating-report) ### TAP Report ```shell $ hurl --test --report-tap build/report.txt *.hurl ``` [Doc](https://hurl.dev/docs/running-tests.html#generating-report) ### JSON Output A structured output of running Hurl files can be obtained with [`--json` option]. Each file will produce a JSON export of the run. ```shell $ hurl --json *.hurl ``` ## Others ### HTTP Version Testing HTTP version (HTTP/1.0, HTTP/1.1, HTTP/2 or HTTP/3) can be done using implicit asserts: ```hurl GET https://foo.com HTTP/3 200 GET https://bar.com HTTP/2 200 ``` [Doc](https://hurl.dev/docs/asserting-response.html#version-status) Or explicit: ```hurl GET https://foo.com HTTP 200 [Asserts] version == "3" GET https://bar.com HTTP 200 [Asserts] version == "2" version toFloat > 1.1 ``` [Doc](https://hurl.dev/docs/asserting-response.html#version-assert) ### IP Address Testing the IP address of the response, as a string. This string may be IPv6 address: ```hurl GET https://foo.com HTTP 200 [Asserts] ip == "2001:0db8:85a3:0000:0000:8a2e:0370:733" ip startsWith "2001" ip isIpv6 ``` ### Polling and Retry Retry request on any errors (asserts, captures, status code, runtime etc...): ```hurl # Create a new job POST https://api.example.org/jobs HTTP 201 [Captures] job_id: jsonpath "$.id" [Asserts] jsonpath "$.state" == "RUNNING" # Pull job status until it is completed GET https://api.example.org/jobs/{{job_id}} [Options] retry: 10 # maximum number of retry, -1 for unlimited retry-interval: 500ms HTTP 200 [Asserts] jsonpath "$.state" == "COMPLETED" ``` [Doc](https://hurl.dev/docs/entry.html#retry) ### Delaying Requests Add delay for every request, or a particular request: ```hurl # Delaying this request by 5 seconds (aka sleep) GET https://example.org/turtle [Options] delay: 5s HTTP 200 # No delay! GET https://example.org/turtle HTTP 200 ``` [Doc](https://hurl.dev/docs/manual.html#delay) ### Skipping Requests ```hurl # a, c, d are run, b is skipped GET https://example.org/a GET https://example.org/b [Options] skip: true GET https://example.org/c GET https://example.org/d ``` [Doc](https://hurl.dev/docs/manual.html#skip) ### Testing Endpoint Performance ```hurl GET https://sample.org/helloworld HTTP * [Asserts] duration < 1000 # Check that response time is less than one second ``` [Doc](https://hurl.dev/docs/asserting-response.html#duration-assert) ### Using SOAP APIs ```hurl POST https://example.org/InStock Content-Type: application/soap+xml; charset=utf-8 SOAPAction: "http://www.w3.org/2003/05/soap-envelope" GOOG HTTP 200 ``` [Doc](https://hurl.dev/docs/request.html#xml-body) ### Capturing and Using a CSRF Token ```hurl GET https://example.org HTTP 200 [Captures] csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)" POST https://example.org/login?user=toto&password=1234 X-CSRF-TOKEN: {{csrf_token}} HTTP 302 ``` [Doc](https://hurl.dev/docs/capturing-response.html#xpath-capture) ### Redacting Secrets Using command-line for known values: ```shell $ hurl --secret token=1234 file.hurl ``` ```hurl POST https://example.org X-Token: {{token}} { "name": "Alice", "value": 100 } HTTP 200 ``` [Doc](https://hurl.dev/docs/templates.html#secrets) Using `redact` for dynamic values: ```hurl # Get an authorization token: GET https://example.org/token HTTP 200 [Captures] token: header "X-Token" redact # Send an authorized request: POST https://example.org X-Token: {{token}} { "name": "Alice", "value": 100 } HTTP 200 ``` [Doc](https://hurl.dev/docs/capturing-response.html#redacting-secrets) ### Checking Byte Order Mark (BOM) in Response Body ```hurl GET https://example.org/data.bin HTTP 200 [Asserts] bytes startsWith hex,efbbbf; ``` [Doc](https://hurl.dev/docs/asserting-response.html#bytes-assert) ### AWS Signature Version 4 Requests Generate signed API requests with [AWS Signature Version 4], as used by several cloud providers. ```hurl POST https://sts.eu-central-1.amazonaws.com/ [Options] aws-sigv4: aws:amz:eu-central-1:sts [Form] Action: GetCallerIdentity Version: 2011-06-15 ``` The Access Key is given per [`--user`], either with command line option or within the [`[Options]`](https://hurl.dev/docs/request.html#options) section: ```hurl POST https://sts.eu-central-1.amazonaws.com/ [Options] aws-sigv4: aws:amz:eu-central-1:sts user: bob=secret [Form] Action: GetCallerIdentity Version: 2011-06-15 ``` [Doc](https://hurl.dev/docs/manual.html#aws-sigv4) ### Using curl Options curl options (for instance [`--resolve`] or [`--connect-to`]) can be used as CLI argument. In this case, they're applicable to each request of an Hurl file. ```shell $ hurl --resolve foo.com:8000:127.0.0.1 foo.hurl ``` Use [`[Options]` section](https://hurl.dev/docs/request.html#options) to configure a specific request: ```hurl GET http://bar.com HTTP 200 GET http://foo.com:8000/resolve [Options] resolve: foo.com:8000:127.0.0.1 HTTP 200 `Hello World!` ``` [Doc](https://hurl.dev/docs/request.html#options) # Manual ## Name hurl - run and test HTTP requests. ## Synopsis **hurl** [options] [FILE...] ## Description **Hurl** is a command line tool that runs HTTP requests defined in a simple plain text format. It can chain requests, capture values and evaluate queries on headers and body response. Hurl is very versatile, it can be used for fetching data and testing HTTP sessions: HTML content, REST / SOAP / GraphQL APIs, or any other XML / JSON based APIs. ```shell $ hurl session.hurl ``` If no input files are specified, input is read from stdin. ```shell $ echo GET http://httpbin.org/get | hurl { "args": {}, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip", "Content-Length": "0", "Host": "httpbin.org", "User-Agent": "hurl/0.99.10", "X-Amzn-Trace-Id": "Root=1-5eedf4c7-520814d64e2f9249ea44e0" }, "origin": "1.2.3.4", "url": "http://httpbin.org/get" } ``` Hurl can take files as input, or directories. In the latter case, Hurl will search files with `.hurl` extension recursively. Output goes to stdout by default. To have output go to a file, use the [`-o, --output`](#output) option: ```shell $ hurl -o output input.hurl ``` By default, Hurl executes all HTTP requests and outputs the response body of the last HTTP call. To have a test oriented output, you can use [`--test`](#test) option: ```shell $ hurl --test *.hurl ``` ## Hurl File Format The Hurl file format is fully documented in [https://hurl.dev/docs/hurl-file.html](https://hurl.dev/docs/hurl-file.html) It consists of one or several HTTP requests ```hurl GET http://example.org/endpoint1 GET http://example.org/endpoint2 ``` ### Capturing values A value from an HTTP response can be-reused for successive HTTP requests. A typical example occurs with CSRF tokens. ```hurl GET https://example.org HTTP 200 # Capture the CSRF token value from html body. [Captures] csrf_token: xpath "normalize-space(//meta[@name='_csrf_token']/@content)" # Do the login ! POST https://example.org/login?user=toto&password=1234 X-CSRF-TOKEN: {{csrf_token}} ``` More information on captures can be found here [https://hurl.dev/docs/capturing-response.html](https://hurl.dev/docs/capturing-response.html) ### Asserts The HTTP response defined in the Hurl file are used to make asserts. Responses are optional. At the minimum, response includes assert on the HTTP status code. ```hurl GET http://example.org HTTP 301 ``` It can also include asserts on the response headers ```hurl GET http://example.org HTTP 301 Location: http://www.example.org ``` Explicit asserts can be included by combining a query and a predicate ```hurl GET http://example.org HTTP 301 [Asserts] xpath "string(//title)" == "301 Moved" ``` With the addition of asserts, Hurl can be used as a testing tool to run scenarios. More information on asserts can be found here [https://hurl.dev/docs/asserting-response.html](https://hurl.dev/docs/asserting-response.html) ## Options Options that exist in curl have exactly the same semantics. Options specified on the command line are defined for every Hurl file's entry, except if they are tagged as cli-only (can not be defined in the Hurl request [Options] entry) For instance: ```shell $ hurl --location foo.hurl ``` will follow redirection for each entry in `foo.hurl`. You can also define an option only for a particular entry with an `[Options]` section. For instance, this Hurl file: ```hurl GET https://example.org HTTP 301 GET https://example.org [Options] location: true HTTP 200 ``` will follow a redirection only for the second entry. | Option | Description | |-------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | --aws-sigv4 <PROVIDER1[:PROVIDER2[:REGION[:SERVICE]]]> | Generate an `Authorization` header with an AWS SigV4 signature.

Use [`-u, --user`](#user) to specify Access Key Id (username) and Secret Key (password).

To use temporary session credentials (e.g. for an AWS IAM Role), add the `X-Amz-Security-Token` header containing the session token.
| | --cacert <FILE> | Specifies the certificate file for peer verification. The file may contain multiple CA certificates and must be in PEM format.
Normally Hurl is built to use a default file for this, so this option is typically used to alter that default file.
| | -E, --cert <CERTIFICATE[:PASSWORD]> | Client certificate file and password.

See also [`--key`](#key).
| | --color | Colorize debug output (the HTTP response output is not colorized).

This is a cli-only option.
| | --compressed | Request a compressed response using one of the algorithms br, gzip, deflate and automatically decompress the content.
| | --connect-timeout <SECONDS> | Maximum time in seconds that you allow Hurl's connection to take.

You can specify time units in the connect timeout expression. Set Hurl to use a connect timeout of 20 seconds with `--connect-timeout 20s` or set it to 35,000 milliseconds with `--connect-timeout 35000ms`. No spaces allowed.

See also [`-m, --max-time`](#max-time).
| | --connect-to <HOST1:PORT1:HOST2:PORT2> | For a request to the given HOST1:PORT1 pair, connect to HOST2:PORT2 instead. This option can be used several times in a command line.

See also [`--resolve`](#resolve).
| | --continue-on-error | Continue executing requests to the end of the Hurl file even when an assert error occurs.
By default, Hurl exits after an assert error in the HTTP response.

Note that this option does not affect the behavior with multiple input Hurl files.

All the input files are executed independently. The result of one file does not affect the execution of the other Hurl files.

This is a cli-only option.
| | -b, --cookie <FILE> | Read cookies from FILE (using the Netscape cookie file format).

Combined with [`-c, --cookie-jar`](#cookie-jar), you can simulate a cookie storage between successive Hurl runs.

This is a cli-only option.
| | -c, --cookie-jar <FILE> | Write cookies to FILE after running the session.
The file will be written using the Netscape cookie file format.

Combined with [`-b, --cookie`](#cookie), you can simulate a cookie storage between successive Hurl runs.

This is a cli-only option.
| | --curl <FILE> | Export each request to a list of curl commands.

This is a cli-only option.
| | --delay <MILLISECONDS> | Sets delay before each request (aka sleep). The delay is not applied to requests that have been retried because of [`--retry`](#retry). See [`--retry-interval`](#retry-interval) to space retried requests.

You can specify time units in the delay expression. Set Hurl to use a delay of 2 seconds with `--delay 2s` or set it to 500 milliseconds with `--delay 500ms`. Supported time units: ms, s, m, h. No spaces allowed.
| | --error-format <FORMAT> | Control the format of error message (short by default or long)

This is a cli-only option.
| | --file-root <DIR> | Set root directory to import files in Hurl. This is used for files in multipart form data, request body and response output.
When it is not explicitly defined, files are relative to the Hurl file's directory.

This is a cli-only option.
| | --from-entry <ENTRY_NUMBER> | Execute Hurl file from ENTRY_NUMBER (starting at 1).

This is a cli-only option.
| | --glob <GLOB> | Specify input files that match the given glob pattern.

Multiple glob flags may be used. This flag supports common Unix glob patterns like *, ? and [].
However, to avoid your shell accidentally expanding glob patterns before Hurl handles them, you must use single quotes or double quotes around each pattern.

This is a cli-only option.
| | -H, --header <HEADER> | Add an extra header to include in information sent. Can be used several times in a command

Do not add newlines or carriage returns
| | -0, --http1.0 | Tells Hurl to use HTTP version 1.0 instead of using its internally preferred HTTP version.
| | --http1.1 | Tells Hurl to use HTTP version 1.1.
| | --http2 | Tells Hurl to use HTTP version 2.
For HTTPS, this means Hurl negotiates HTTP/2 in the TLS handshake. Hurl does this by default.
For HTTP, this means Hurl attempts to upgrade the request to HTTP/2 using the Upgrade: request header.
| | --http3 | Tells Hurl to try HTTP/3 to the host in the URL, but fallback to earlier HTTP versions if the HTTP/3 connection establishment fails. HTTP/3 is only available for HTTPS and not for HTTP URLs.
| | --ignore-asserts | Ignore all asserts defined in the Hurl file.

This is a cli-only option.
| | -i, --include | Include the HTTP headers in the output

This is a cli-only option.
| | -k, --insecure | This option explicitly allows Hurl to perform "insecure" SSL connections and transfers.
| | -4, --ipv4 | This option tells Hurl to use IPv4 addresses only when resolving host names, and not for example try IPv6.
| | -6, --ipv6 | This option tells Hurl to use IPv6 addresses only when resolving host names, and not for example try IPv4.
| | --jobs <NUM> | Maximum number of parallel jobs in parallel mode. Default value corresponds (in most cases) to the
current amount of CPUs.

See also [`--parallel`](#parallel).

This is a cli-only option.
| | --json | Output each Hurl file result to JSON. The format is very closed to HAR format.

This is a cli-only option.
| | --key <KEY> | Private key file name.
| | --limit-rate <SPEED> | Specify the maximum transfer rate you want Hurl to use, for both downloads and uploads. This feature is useful if you have a limited pipe and you would like your transfer not to use your entire bandwidth. To make it slower than it otherwise would be.
The given speed is measured in bytes/second.
| | -L, --location | Follow redirect. To limit the amount of redirects to follow use the [`--max-redirs`](#max-redirs) option
| | --location-trusted | Like [`-L, --location`](#location), but allows sending the name + password to all hosts that the site may redirect to.
This may or may not introduce a security breach if the site redirects you to a site to which you send your authentication info (which is plaintext in the case of HTTP Basic authentication).
| | --max-filesize <BYTES> | Specify the maximum size in bytes of a file to download. If the file requested is larger than this value, the transfer does not start.

This is a cli-only option.
| | --max-redirs <NUM> | Set maximum number of redirection-followings allowed

By default, the limit is set to 50 redirections. Set this option to -1 to make it unlimited.
| | -m, --max-time <SECONDS> | Maximum time in seconds that you allow a request/response to take. This is the standard timeout.

You can specify time units in the maximum time expression. Set Hurl to use a maximum time of 20 seconds with `--max-time 20s` or set it to 35,000 milliseconds with `--max-time 35000ms`. No spaces allowed.

See also [`--connect-timeout`](#connect-timeout).
| | --negotiate | Tell Hurl to use Negotiate (SPNEGO) authentication.
| | -n, --netrc | Scan the .netrc file in the user's home directory for the username and password.

See also [`--netrc-file`](#netrc-file) and [`--netrc-optional`](#netrc-optional).
| | --netrc-file <FILE> | Like [`--netrc`](#netrc), but provide the path to the netrc file.

See also [`--netrc-optional`](#netrc-optional).
| | --netrc-optional | Similar to [`--netrc`](#netrc), but make the .netrc usage optional.

See also [`--netrc-file`](#netrc-file).
| | --no-color | Do not colorize output.

This is a cli-only option.
| | --no-output | Suppress output. By default, Hurl outputs the body of the last response.

This is a cli-only option.
| | --no-pretty | Do not prettify response output for supported content type (JSON only for the moment). By default, output is prettified if
standard output is a terminal.

This is a cli-only option.
| | --noproxy <HOST(S)> | Comma-separated list of hosts which do not use a proxy.

Override value from Environment variable no_proxy.
| | --ntlm | Tell Hurl to use NTLM authentication
| | -o, --output <FILE> | Write output to FILE instead of stdout. Use '-' for stdout in [Options] sections.
| | --parallel | Run files in parallel.

Each Hurl file is executed in its own worker thread, without sharing anything with the other workers. The default run mode is sequential. Parallel execution is by default in [`--test`](#test) mode.

See also [`--jobs`](#jobs).

This is a cli-only option.
| | --path-as-is | Tell Hurl to not handle sequences of /../ or /./ in the given URL path. Normally Hurl will squash or merge them according to standards but with this option set you tell it not to do that.
| | --pinnedpubkey <HASHES> | When negotiating a TLS or SSL connection, the server sends a certificate indicating its identity. A public key is extracted from this certificate and if it does not exactly match the public key provided to this option, Hurl aborts the connection before sending or receiving any data.
| | --pretty | Prettify response output for supported content type (JSON only for the moment). By default, JSON response is prettified if standard output is a terminal, and colorized, see[`--no-color`](#no-color) to format without color.

This is a cli-only option.
| | --progress-bar | Display a progress bar in test mode. The progress bar is displayed only in interactive TTYs. This option forces the progress bar to be displayed even in non-interactive TTYs.

This is a cli-only option.
| | -x, --proxy <[PROTOCOL://]HOST[:PORT]> | Use the specified proxy.
| | --repeat <NUM> | Repeat the input files sequence NUM times, -1 for infinite loop. Given a.hurl, b.hurl, c.hurl as input, repeat two
times will run a.hurl, b.hurl, c.hurl, a.hurl, b.hurl, c.hurl.
| | --report-html <DIR> | Generate HTML report in DIR.

If the HTML report already exists, it will be updated with the new test results.

This is a cli-only option.
| | --report-json <DIR> | Generate JSON report in DIR.

If the JSON report already exists, it will be updated with the new test results.

This is a cli-only option.
| | --report-junit <FILE> | Generate JUnit File.

If the FILE report already exists, it will be updated with the new test results.

This is a cli-only option.
| | --report-tap <FILE> | Generate TAP report.

If the FILE report already exists, it will be updated with the new test results.

This is a cli-only option.
| | --resolve <HOST:PORT:ADDR> | Provide a custom address for a specific host and port pair. Using this, you can make the Hurl requests(s) use a specified address and prevent the otherwise normally resolved address to be used. Consider it a sort of /etc/hosts alternative provided on the command line.
| | --retry <NUM> | Maximum number of retries, 0 for no retries, -1 for unlimited retries. Retry happens if any error occurs (asserts, captures, runtimes etc...).
| | --retry-interval <MILLISECONDS> | Duration in milliseconds between each retry. Default is 1000 ms.

You can specify time units in the retry interval expression. Set Hurl to use a retry interval of 2 seconds with `--retry-interval 2s` or set it to 500 milliseconds with `--retry-interval 500ms`. No spaces allowed.
| | --secret <NAME=VALUE> | Define secret value to be redacted from logs and report. When defined, secrets can be used as variable everywhere variables are used.

This is a cli-only option.
| | --secrets-file <FILE> | Define a secrets file in which you define your secrets

Each secret is defined as name=value exactly as with [`--secret`](#secret) option.

Note that defining a secret twice produces an error.

This is a cli-only option.
| | --ssl-no-revoke | (Windows) This option tells Hurl to disable certificate revocation checks. WARNING: this option loosens the SSL security, and by using this flag you ask for exactly that.

This is a cli-only option.
| | --test | Activate test mode: with this, the HTTP response is not outputted anymore, progress is reported for each Hurl file tested, and a text summary is displayed when all files have been run.

In test mode, files are executed in parallel. To run test in a sequential way use `--job 1`.

See also [`--jobs`](#jobs).

This is a cli-only option.
| | --to-entry <ENTRY_NUMBER> | Execute Hurl file to ENTRY_NUMBER (starting at 1).
Ignore the remaining of the file. It is useful for debugging a session.

This is a cli-only option.
| | --unix-socket <PATH> | (HTTP) Connect through this Unix domain socket, instead of using the network.
| | -u, --user <USER:PASSWORD> | Add basic Authentication header to each request.
| | -A, --user-agent <NAME> | Specify the User-Agent string to send to the HTTP server.

This is a cli-only option.
| | --variable <NAME=VALUE> | Define variable (name/value) to be used in Hurl templates.
| | --variables-file <FILE> | Set properties file in which your define your variables.

Each variable is defined as name=value exactly as with [`--variable`](#variable) option.

Note that defining a variable twice produces an error.

This is a cli-only option.
| | -v, --verbose | Turn on verbose output on standard error stream.
Useful for debugging.

A line starting with '>' means data sent by Hurl.
A line staring with '<' means data received by Hurl.
A line starting with '*' means additional info provided by Hurl.

If you only want HTTP headers in the output, [`-i, --include`](#include) might be the option you're looking for.
| | --very-verbose | Turn on more verbose output on standard error stream.

In contrast to [`--verbose`](#verbose) option, this option outputs the full HTTP body request and response on standard error. In addition, lines starting with '**' are libcurl debug logs.
| | -h, --help | Usage help. This lists all current command line options with a short description.
| | -V, --version | Prints version information
| ## Environment Environment variables can only be specified in lowercase. Using an environment variable to set the proxy has the same effect as using the [`-x, --proxy`](#proxy) option. | Variable | Description | |--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| | `http_proxy [PROTOCOL://][:PORT]` | Sets the proxy server to use for HTTP.
| | `https_proxy [PROTOCOL://][:PORT]` | Sets the proxy server to use for HTTPS.
| | `all_proxy [PROTOCOL://][:PORT]` | Sets the proxy server to use if no protocol-specific proxy is set.
| | `no_proxy ` | List of host names that shouldn't go through any proxy.
| | `HURL_VARIABLE_name value` | Define variable (name/value) to be used in Hurl templates. This is similar to [`--variable`](#variable) and [`--variables-file`](#variables-file) options.
| | `HURL_SECRET_name value` | Define secret (name/value) to be used in Hurl templates. This is similar to [`--secret`](#secret) and [`--secrets-file`](#secrets-file) options.
| | `NO_COLOR` | When set to a non-empty string, do not colorize output (see [`--no-color`](#no-color) option).
| ## Exit Codes | Value | Description | |-------|---------------------------------------------------------| | `0` | Success.
| | `1` | Failed to parse command-line options.
| | `2` | Input File Parsing Error.
| | `3` | Runtime error (such as failure to connect to host).
| | `4` | Assert Error.
| ## WWW [https://hurl.dev](https://hurl.dev) ## See Also curl(1) hurlfmt(1) # Installation ## Binaries Installation ### Linux Precompiled binary (depending on libc >=2.35) is available at [Hurl latest GitHub release]: ```shell $ INSTALL_DIR=/tmp $ VERSION=7.1.0 $ curl --silent --location https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl-$VERSION-x86_64-unknown-linux-gnu.tar.gz | tar xvz -C $INSTALL_DIR $ export PATH=$INSTALL_DIR/hurl-$VERSION-x86_64-unknown-linux-gnu/bin:$PATH ``` #### Debian / Ubuntu For Debian >=12 / Ubuntu >=22.04, Hurl can be installed using a binary .deb file provided in each Hurl release. ```shell $ VERSION=7.1.0 $ curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb $ sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.deb ``` For Ubuntu >=18.04, Hurl can be installed from `ppa:lepapareil/hurl` ```shell $ VERSION=7.1.0 $ sudo apt-add-repository -y ppa:lepapareil/hurl $ sudo apt install hurl="${VERSION}"* ``` #### Alpine Hurl is available on `testing` channel. ```shell $ apk add --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing hurl ``` #### Arch Linux / Manjaro Hurl is available on [extra] channel. ```shell $ pacman -Sy hurl ``` #### NixOS / Nix [NixOS / Nix package] is available on stable channel. ### macOS Precompiled binaries for Intel and ARM CPUs are available at [Hurl latest GitHub release]. #### Homebrew ```shell $ brew install hurl ``` #### MacPorts ```shell $ sudo port install hurl ``` ### FreeBSD ```shell $ sudo pkg install hurl ``` ### Windows Windows requires the [Visual C++ Redistributable Package] to be installed manually, as this is not included in the installer. #### Zip File Hurl can be installed from a standalone zip file at [Hurl latest GitHub release]. You will need to update your `PATH` variable. #### Installer An executable installer is also available at [Hurl latest GitHub release]. #### Chocolatey ```shell $ choco install hurl ``` #### Scoop ```shell $ scoop install hurl ``` #### Windows Package Manager ```shell $ winget install hurl ``` ### Cargo If you're a Rust programmer, Hurl can be installed with cargo. ```shell $ cargo install --locked hurl ``` ### conda-forge ```shell $ conda install -c conda-forge hurl ``` Hurl can also be installed with [`conda-forge`] powered package manager like [`pixi`]. ### Docker ```shell $ docker pull ghcr.io/orange-opensource/hurl:latest ``` ### npm ```shell $ npm install --save-dev @orangeopensource/hurl ``` ## Building From Sources Hurl sources are available in [GitHub]. ### Build on Linux Hurl depends on libssl, libcurl and libxml2 native libraries. You will need their development files in your platform. #### Debian based distributions ```shell $ apt install -y build-essential pkg-config libssl-dev libcurl4-openssl-dev libxml2-dev libclang-dev ``` #### Fedora based distributions ```shell $ dnf install -y pkgconf-pkg-config gcc openssl-devel libxml2-devel clang-devel ``` #### Red Hat based distributions ```shell $ yum install -y pkg-config gcc openssl-devel libxml2-devel clang-devel ``` #### Arch based distributions ```shell $ pacman -S --noconfirm pkgconf gcc glibc openssl libxml2 clang ``` #### Alpine based distributions ```shell $ apk add curl-dev gcc libxml2-dev musl-dev openssl-dev clang-dev ``` ### Build on macOS ```shell $ xcode-select --install $ brew install pkg-config ``` Hurl is written in [Rust]. You should [install] the latest stable release. ```shell $ curl https://sh.rustup.rs -sSf | sh -s -- -y $ source $HOME/.cargo/env $ rustc --version $ cargo --version ``` Then build hurl: ```shell $ git clone https://github.com/Orange-OpenSource/hurl $ cd hurl $ cargo build --release $ ./target/release/hurl --version ``` ### Build on Windows Please follow the [contrib on Windows section]. [XPath]: https://en.wikipedia.org/wiki/XPath [JSONPath]: https://goessner.net/articles/JsonPath/ [Rust]: https://www.rust-lang.org [curl]: https://curl.se [the installation section]: https://hurl.dev/docs/installation.html [Feedback, suggestion, bugs or improvements]: https://github.com/Orange-OpenSource/hurl/issues [License]: https://hurl.dev/docs/license.html [Tutorial]: https://hurl.dev/docs/tutorial/your-first-hurl-file.html [Documentation]: https://hurl.dev/docs/installation.html [Blog]: https://hurl.dev/blog/ [GitHub]: https://github.com/Orange-OpenSource/hurl [libcurl]: https://curl.se/libcurl/ [star Hurl on GitHub]: https://github.com/Orange-OpenSource/hurl/stargazers [HTML]: /docs/standalone/hurl-7.0.0.html [PDF]: /docs/standalone/hurl-7.0.0.pdf [Markdown]: https://hurl.dev/docs/standalone/hurl-7.0.0.html [JSON body]: https://hurl.dev/docs/request.html#json-body [XML body]: https://hurl.dev/docs/request.html#xml-body [XML multiline string body]: https://hurl.dev/docs/request.html#multiline-string-body [multiline string body]: https://hurl.dev/docs/request.html#multiline-string-body [predicates]: https://hurl.dev/docs/asserting-response.html#predicates [JSONPath]: https://goessner.net/articles/JsonPath/ [Basic authentication]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#basic_authentication_scheme [`Authorization` header]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization [Hurl tests suite]: https://github.com/Orange-OpenSource/hurl/tree/master/integration/hurl/tests_ok [Authorization]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization [`-u/--user` option]: https://hurl.dev/docs/manual.html#user [curl]: https://curl.se [entry]: https://hurl.dev/docs/entry.html [`--test` option]: https://hurl.dev/docs/manual.html#test [`--user`]: https://hurl.dev/docs/manual.html#user [Hurl templates]: https://hurl.dev/docs/templates.html [AWS Signature Version 4]: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html [Captures]: https://hurl.dev/docs/capturing-response.html [`--json` option]: https://hurl.dev/docs/manual.html#json [`--resolve`]: https://hurl.dev/docs/manual.html#resolve [`--connect-to`]: https://hurl.dev/docs/manual.html#connect-to [Functions]: https://hurl.dev/docs/templates.html#functions [`--location`]: https://hurl.dev/docs/manual.html#location [`--location-trusted`]: https://hurl.dev/docs/manual.html#location-trusted [GitHub]: https://github.com/Orange-OpenSource/hurl [Hurl latest GitHub release]: https://github.com/Orange-OpenSource/hurl/releases/latest [Visual C++ Redistributable Package]: https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170#latest-microsoft-visual-c-redistributable-version [install]: https://www.rust-lang.org/tools/install [Rust]: https://www.rust-lang.org [contrib on Windows section]: https://github.com/Orange-OpenSource/hurl/blob/master/contrib/windows/README.md [NixOS / Nix package]: https://search.nixos.org/packages?from=0&size=1&sort=relevance&type=packages&query=hurl [`conda-forge`]: https://conda-forge.org [`pixi`]: https://prefix.dev [extra]: https://archlinux.org/packages/extra/x86_64/hurl/ hurl-7.1.0/build.rs000064400000000000000000000023011046102023000122440ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::path::Path; use cc::Build; #[cfg(windows)] use winres::WindowsResource; #[cfg(windows)] fn set_icon() { let mut res = WindowsResource::new(); res.set_icon("../../bin/windows/logo.ico"); res.compile().unwrap(); } #[cfg(unix)] fn set_icon() {} fn main() { let project_root = Path::new(env!("CARGO_MANIFEST_DIR")); let native_src = project_root.join("native"); set_icon(); Build::new() .file(native_src.join("libxml.c")) .flag_if_supported("-Wno-unused-parameter") // unused parameter in silent callback .compile("mylib"); } hurl-7.1.0/native/libxml.c000064400000000000000000000003001046102023000135150ustar 00000000000000// This callback will prevent from outputting error messages // It could not be implemented in Rust, because the function is variadic void silentErrorFunc(void *ctx, const char * msg, ...) { }hurl-7.1.0/src/cli/error.rs000064400000000000000000000044111046102023000136400ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::{fmt, io}; use hurl::parallel::error::JobError; use hurl::report; /// All possibles errors from the command line. /// /// Note that assert "errors" are not represented here: assert error occurred when the program /// has successfully run and returned results with false assertions. We don't consider these false /// asserts as program errors. #[derive(Clone, Debug, PartialEq, Eq)] pub enum CliError { /// An error has occurred during reading of an input. InputRead(String), /// The input is not a valid Hurl file. Parsing, /// An error has occurred during writing of an output. OutputWrite(String), /// A generic i/O error has happened. GenericIO(String), } impl From for CliError { fn from(error: report::ReportError) -> Self { CliError::GenericIO(error.to_string()) } } impl From for CliError { fn from(error: JobError) -> Self { match error { JobError::InputRead(message) => CliError::GenericIO(message), JobError::Parsing => CliError::Parsing, JobError::OutputWrite(message) => CliError::OutputWrite(message), } } } impl fmt::Display for CliError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { CliError::InputRead(message) => write!(f, "{message}"), CliError::Parsing => Ok(()), CliError::OutputWrite(message) => write!(f, "{message}"), CliError::GenericIO(message) => write!(f, "{message}"), } } } impl From for CliError { fn from(error: io::Error) -> Self { CliError::GenericIO(error.to_string()) } } hurl-7.1.0/src/cli/interactive.rs000064400000000000000000000043251046102023000150300ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::ast::Entry; #[cfg(target_family = "unix")] use hurl_core::ast::Request; #[cfg(target_family = "unix")] use { std::io::{stderr, stdin, Write}, termion::event::Key, termion::input::TermRead, termion::raw::IntoRawMode, }; /// Interactively asks user to execute `entry` or quit. #[cfg(target_family = "unix")] pub fn pre_entry(entry: &Entry) -> bool { eprintln!("\nInteractive mode"); eprintln!("\nNext request:"); eprintln!(); log_request(&entry.request); // In raw mode, "\n" only means "go one line down", not "line break" // To effectively go do the next new line, we have to write "\r\n". let mut stderr = stderr().into_raw_mode().unwrap(); write!( stderr, "\r\nPress Q (Quit) or C (Continue){}\r\n", termion::cursor::Hide ) .unwrap(); stderr.flush().unwrap(); let mut exit = false; for c in stdin().keys() { print!("\r"); match c.unwrap() { Key::Char('q') => { exit = true; break; } Key::Char('c') => { break; } _ => {} } } print!("{}\r{}", termion::clear::CurrentLine, termion::cursor::Show); exit } #[allow(dead_code)] #[cfg(target_family = "unix")] fn log_request(request: &Request) { let method = &request.method; let url = &request.url; eprintln!("{method} {url}"); } #[cfg(target_family = "windows")] pub fn pre_entry(_: &Entry) -> bool { eprintln!("Interactive not supported yet in windows!"); true } pub fn post_entry() -> bool { false } hurl-7.1.0/src/cli/logger.rs000064400000000000000000000040301046102023000137630ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::text::{Format, Style, StyledString}; /// A simple logger to log app related event (start, high levels error, etc...). pub struct BaseLogger { /// Format of the message in the terminal: ANSI or plain. format: Format, /// Prints debug message or not. verbose: bool, } impl BaseLogger { /// Creates a new base logger using `color` and `verbose`. pub fn new(color: bool, verbose: bool) -> BaseLogger { let format = if color { Format::Ansi } else { Format::Plain }; BaseLogger { format, verbose } } /// Prints an informational `message` on standard error. pub fn info(&self, message: &str) { eprintln!("{message}"); } /// Prints a debug `message` on standard error if the logger is in verbose mode. pub fn debug(&self, message: &str) { if !self.verbose { return; } let mut s = StyledString::new(); s.push_with("*", Style::new().blue().bold()); if !message.is_empty() { s.push(&format!(" {message}")); } eprintln!("{}", s.to_string(self.format)); } /// Prints an error `message` on standard error. pub fn error(&self, message: &str) { let mut s = StyledString::new(); s.push_with("error", Style::new().red().bold()); s.push(": "); s.push_with(message, Style::new().bold()); eprintln!("{}", s.to_string(self.format)); } } hurl-7.1.0/src/cli/mod.rs000064400000000000000000000015431046102023000132710ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ mod error; mod interactive; mod logger; pub(crate) mod options; mod summary; pub(crate) use self::error::CliError; pub(crate) use self::logger::BaseLogger; pub(crate) use self::options::OutputType; pub(crate) use self::summary::summary; hurl-7.1.0/src/cli/options/commands.rs000064400000000000000000000476731046102023000160240ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ // Generated by bin/spec/options/generate_source.py - Do not modify pub fn input_files() -> clap::Arg { clap::Arg::new("input_files") .value_name("FILES") .help("Set the input file to use") .required(false) .index(1) .num_args(1..) } pub fn aws_sigv4() -> clap::Arg { clap::Arg::new("aws_sigv4") .long("aws-sigv4") .value_name("PROVIDER1[:PROVIDER2[:REGION[:SERVICE]]]") .help("Use AWS V4 signature authentication in the transfer") .help_heading("HTTP options") .num_args(1) } pub fn cacert_file() -> clap::Arg { clap::Arg::new("cacert_file") .long("cacert") .value_name("FILE") .help("CA certificate to verify peer against (PEM format)") .help_heading("HTTP options") .num_args(1) } pub fn client_cert_file() -> clap::Arg { clap::Arg::new("client_cert_file") .long("cert") .short('E') .value_name("CERTIFICATE[:PASSWORD]") .help("Client certificate file and password") .help_heading("HTTP options") .num_args(1) } pub fn client_key_file() -> clap::Arg { clap::Arg::new("client_key_file") .long("key") .value_name("KEY") .help("Private key file name") .help_heading("HTTP options") .num_args(1) } pub fn color() -> clap::Arg { clap::Arg::new("color") .long("color") .help("Colorize output") .help_heading("Output options") .conflicts_with("no_color") .action(clap::ArgAction::SetTrue) } pub fn compressed() -> clap::Arg { clap::Arg::new("compressed") .long("compressed") .help("Request compressed response (using deflate or gzip)") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn connect_timeout() -> clap::Arg { clap::Arg::new("connect_timeout") .long("connect-timeout") .value_name("SECONDS") .help("Maximum time allowed for connection [default: 300]") .help_heading("HTTP options") .num_args(1) } pub fn connect_to() -> clap::Arg { clap::Arg::new("connect_to") .long("connect-to") .value_name("HOST1:PORT1:HOST2:PORT2") .help("For a request to the given HOST1:PORT1 pair, connect to HOST2:PORT2 instead") .help_heading("HTTP options") .num_args(1) .action(clap::ArgAction::Append) } pub fn continue_on_error() -> clap::Arg { clap::Arg::new("continue_on_error") .long("continue-on-error") .help("Continue executing requests even if an error occurs") .help_heading("Run options") .action(clap::ArgAction::SetTrue) } pub fn cookies_input_file() -> clap::Arg { clap::Arg::new("cookies_input_file") .long("cookie") .short('b') .value_name("FILE") .help("Read cookies from FILE") .help_heading("Other options") .num_args(1) } pub fn cookies_output_file() -> clap::Arg { clap::Arg::new("cookies_output_file") .long("cookie-jar") .short('c') .value_name("FILE") .help("Write cookies to FILE after running the session") .help_heading("Other options") .num_args(1) } pub fn curl() -> clap::Arg { clap::Arg::new("curl") .long("curl") .value_name("FILE") .help("Export each request to a list of curl commands") .help_heading("Output options") .num_args(1) } pub fn delay() -> clap::Arg { clap::Arg::new("delay") .long("delay") .value_name("MILLISECONDS") .help("Sets delay before each request (aka sleep) [default: 0]") .help_heading("Run options") .num_args(1) } pub fn error_format() -> clap::Arg { clap::Arg::new("error_format") .long("error-format") .value_name("FORMAT") .value_parser(["short", "long"]) .help("Control the format of error messages [default: short]") .help_heading("Output options") .num_args(1) } pub fn file_root() -> clap::Arg { clap::Arg::new("file_root") .long("file-root") .value_name("DIR") .help("Set root directory to import files [default: input file directory]") .help_heading("Other options") .num_args(1) } pub fn follow_location() -> clap::Arg { clap::Arg::new("follow_location") .long("location") .short('L') .help("Follow redirects") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn follow_location_trusted() -> clap::Arg { clap::Arg::new("follow_location_trusted") .long("location-trusted") .help("Follow redirects but allows sending the name + password to all hosts that the site may redirect to") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn from_entry() -> clap::Arg { clap::Arg::new("from_entry") .long("from-entry") .value_name("ENTRY_NUMBER") .value_parser(clap::value_parser!(u32).range(1..)) .help("Execute Hurl file from ENTRY_NUMBER (starting at 1)") .help_heading("Run options") .conflicts_with("interactive") .num_args(1) } pub fn glob() -> clap::Arg { clap::Arg::new("glob") .long("glob") .value_name("GLOB") .help("Specify input files that match the given GLOB. Multiple glob flags may be used") .help_heading("Other options") .num_args(1) .action(clap::ArgAction::Append) } pub fn header() -> clap::Arg { clap::Arg::new("header") .long("header") .short('H') .value_name("HEADER") .help("Pass custom header(s) to server") .help_heading("HTTP options") .num_args(1) .action(clap::ArgAction::Append) } pub fn http10() -> clap::Arg { clap::Arg::new("http10") .long("http1.0") .short('0') .help("Tell Hurl to use HTTP version 1.0") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn http11() -> clap::Arg { clap::Arg::new("http11") .long("http1.1") .help("Tell Hurl to use HTTP version 1.1") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn http2() -> clap::Arg { clap::Arg::new("http2") .long("http2") .help("Tell Hurl to use HTTP version 2") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn http3() -> clap::Arg { clap::Arg::new("http3") .long("http3") .help("Tell Hurl to use HTTP version 3") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn ignore_asserts() -> clap::Arg { clap::Arg::new("ignore_asserts") .long("ignore-asserts") .help("Ignore asserts defined in the Hurl file") .help_heading("Run options") .action(clap::ArgAction::SetTrue) } pub fn include() -> clap::Arg { clap::Arg::new("include") .long("include") .short('i') .help("Include the HTTP headers in the output") .help_heading("Output options") .action(clap::ArgAction::SetTrue) } pub fn insecure() -> clap::Arg { clap::Arg::new("insecure") .long("insecure") .short('k') .help("Allow insecure SSL connections") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn interactive() -> clap::Arg { clap::Arg::new("interactive") .long("interactive") .help("Turn on interactive mode") .help_heading("Run options") .conflicts_with("to_entry") .action(clap::ArgAction::SetTrue) .hide(true) } pub fn ipv4() -> clap::Arg { clap::Arg::new("ipv4") .long("ipv4") .short('4') .help("Tell Hurl to use IPv4 addresses only when resolving host names, and not for example try IPv6") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn ipv6() -> clap::Arg { clap::Arg::new("ipv6") .long("ipv6") .short('6') .help("Tell Hurl to use IPv6 addresses only when resolving host names, and not for example try IPv4") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn jobs() -> clap::Arg { clap::Arg::new("jobs") .long("jobs") .value_name("NUM") .value_parser(clap::value_parser!(u32).range(1..)) .help("Maximum number of parallel jobs, 0 to disable parallel execution") .help_heading("Run options") .num_args(1) } pub fn json() -> clap::Arg { clap::Arg::new("json") .long("json") .help("Output each Hurl file result to JSON") .help_heading("Output options") .conflicts_with("no_output") .action(clap::ArgAction::SetTrue) } pub fn limit_rate() -> clap::Arg { clap::Arg::new("limit_rate") .long("limit-rate") .value_name("SPEED") .value_parser(clap::value_parser!(u64)) .help("Specify the maximum transfer rate in bytes/second, for both downloads and uploads") .help_heading("HTTP options") .num_args(1) } pub fn max_filesize() -> clap::Arg { clap::Arg::new("max_filesize") .long("max-filesize") .value_name("BYTES") .value_parser(clap::value_parser!(u64)) .help("Specify the maximum size in bytes of a file to download") .help_heading("HTTP options") .num_args(1) } pub fn max_redirects() -> clap::Arg { clap::Arg::new("max_redirects") .long("max-redirs") .value_name("NUM") .value_parser(clap::value_parser!(i32).range(-1..)) .allow_hyphen_values(true) .help("Maximum number of redirects allowed, -1 for unlimited redirects [default: 50]") .help_heading("HTTP options") .num_args(1) } pub fn max_time() -> clap::Arg { clap::Arg::new("max_time") .long("max-time") .short('m') .value_name("SECONDS") .help("Maximum time allowed for the transfer [default: 300]") .help_heading("HTTP options") .num_args(1) } pub fn negotiate() -> clap::Arg { clap::Arg::new("negotiate") .long("negotiate") .help("Tell Hurl to use Negotiate (SPNEGO) authentication") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn netrc() -> clap::Arg { clap::Arg::new("netrc") .long("netrc") .short('n') .help("Must read .netrc for username and password") .help_heading("Other options") .conflicts_with("netrc_file") .conflicts_with("netrc_optional") .action(clap::ArgAction::SetTrue) } pub fn netrc_file() -> clap::Arg { clap::Arg::new("netrc_file") .long("netrc-file") .value_name("FILE") .help("Specify FILE for .netrc") .help_heading("Other options") .conflicts_with("netrc") .num_args(1) } pub fn netrc_optional() -> clap::Arg { clap::Arg::new("netrc_optional") .long("netrc-optional") .help("Use either .netrc or the URL") .help_heading("Other options") .conflicts_with("netrc") .action(clap::ArgAction::SetTrue) } pub fn no_color() -> clap::Arg { clap::Arg::new("no_color") .long("no-color") .help("Do not colorize output") .help_heading("Output options") .conflicts_with("color") .action(clap::ArgAction::SetTrue) } pub fn no_output() -> clap::Arg { clap::Arg::new("no_output") .long("no-output") .help("Suppress output. By default, Hurl outputs the body of the last response") .help_heading("Output options") .conflicts_with("json") .action(clap::ArgAction::SetTrue) } pub fn no_pretty() -> clap::Arg { clap::Arg::new("no_pretty") .long("no-pretty") .help("Do not prettify response output") .help_heading("Output options") .conflicts_with("pretty") .action(clap::ArgAction::SetTrue) } pub fn noproxy() -> clap::Arg { clap::Arg::new("noproxy") .long("noproxy") .value_name("HOST(S)") .help("List of hosts which do not use proxy") .help_heading("HTTP options") .num_args(1) } pub fn ntlm() -> clap::Arg { clap::Arg::new("ntlm") .long("ntlm") .help("Tell Hurl to use NTLM authentication") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn output() -> clap::Arg { clap::Arg::new("output") .long("output") .short('o') .value_name("FILE") .help("Write to FILE instead of stdout") .help_heading("Output options") .num_args(1) } pub fn parallel() -> clap::Arg { clap::Arg::new("parallel") .long("parallel") .help("Run files in parallel (default in test mode)") .help_heading("Run options") .action(clap::ArgAction::SetTrue) } pub fn path_as_is() -> clap::Arg { clap::Arg::new("path_as_is") .long("path-as-is") .help("Tell Hurl to not handle sequences of /../ or /./ in the given URL path") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn pinned_pub_key() -> clap::Arg { clap::Arg::new("pinned_pub_key") .long("pinnedpubkey") .value_name("HASHES") .help("Public key to verify peer against") .help_heading("HTTP options") .num_args(1) } pub fn pretty() -> clap::Arg { clap::Arg::new("pretty") .long("pretty") .help("Prettify JSON response output") .help_heading("Output options") .action(clap::ArgAction::SetTrue) } pub fn progress_bar() -> clap::Arg { clap::Arg::new("progress_bar") .long("progress-bar") .help("Display a progress bar in test mode") .help_heading("Output options") .action(clap::ArgAction::SetTrue) } pub fn proxy() -> clap::Arg { clap::Arg::new("proxy") .long("proxy") .short('x') .value_name("[PROTOCOL://]HOST[:PORT]") .help("Use proxy on given PROTOCOL/HOST/PORT") .help_heading("HTTP options") .num_args(1) } pub fn repeat() -> clap::Arg { clap::Arg::new("repeat") .long("repeat") .value_name("NUM") .value_parser(clap::value_parser!(i32).range(-1..)) .allow_hyphen_values(true) .help("Repeat the input files sequence NUM times, -1 for infinite loop") .help_heading("Run options") .num_args(1) } pub fn report_html() -> clap::Arg { clap::Arg::new("report_html") .long("report-html") .value_name("DIR") .help("Generate HTML report to DIR") .help_heading("Report options") .num_args(1) } pub fn report_json() -> clap::Arg { clap::Arg::new("report_json") .long("report-json") .value_name("DIR") .help("Generate JSON report to DIR") .help_heading("Report options") .num_args(1) } pub fn report_junit() -> clap::Arg { clap::Arg::new("report_junit") .long("report-junit") .value_name("FILE") .help("Write a JUnit XML report to FILE") .help_heading("Report options") .num_args(1) } pub fn report_tap() -> clap::Arg { clap::Arg::new("report_tap") .long("report-tap") .value_name("FILE") .help("Write a TAP report to FILE") .help_heading("Report options") .num_args(1) } pub fn resolve() -> clap::Arg { clap::Arg::new("resolve") .long("resolve") .value_name("HOST:PORT:ADDR") .help("Provide a custom address for a specific HOST and PORT pair") .help_heading("HTTP options") .num_args(1) .action(clap::ArgAction::Append) } pub fn retry() -> clap::Arg { clap::Arg::new("retry") .long("retry") .value_name("NUM") .value_parser(clap::value_parser!(i32).range(-1..)) .allow_hyphen_values(true) .help("Maximum number of retries, 0 for no retries, -1 for unlimited retries") .help_heading("Run options") .num_args(1) } pub fn retry_interval() -> clap::Arg { clap::Arg::new("retry_interval") .long("retry-interval") .value_name("MILLISECONDS") .help("Interval in milliseconds before a retry [default: 1000]") .help_heading("Run options") .num_args(1) } pub fn secret() -> clap::Arg { clap::Arg::new("secret") .long("secret") .value_name("NAME=VALUE") .help("Define a variable which value is secret") .help_heading("Run options") .num_args(1) .action(clap::ArgAction::Append) } pub fn secrets_file() -> clap::Arg { clap::Arg::new("secrets_file") .long("secrets-file") .value_name("FILE") .help("Define a secrets file in which you define your secrets") .help_heading("Run options") .num_args(1) .action(clap::ArgAction::Append) } pub fn ssl_no_revoke() -> clap::Arg { clap::Arg::new("ssl_no_revoke") .long("ssl-no-revoke") .help("(Windows) Tell Hurl to disable certificate revocation checks") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn test() -> clap::Arg { clap::Arg::new("test") .long("test") .help("Activate test mode (use parallel execution)") .help_heading("Run options") .action(clap::ArgAction::SetTrue) } pub fn to_entry() -> clap::Arg { clap::Arg::new("to_entry") .long("to-entry") .value_name("ENTRY_NUMBER") .value_parser(clap::value_parser!(u32).range(1..)) .help("Execute Hurl file to ENTRY_NUMBER (starting at 1)") .help_heading("Run options") .conflicts_with("interactive") .num_args(1) } pub fn unix_socket() -> clap::Arg { clap::Arg::new("unix_socket") .long("unix-socket") .value_name("PATH") .help("(HTTP) Connect through this Unix domain socket, instead of using the network") .help_heading("HTTP options") .num_args(1) } pub fn user() -> clap::Arg { clap::Arg::new("user") .long("user") .short('u') .value_name("USER:PASSWORD") .help("Add basic Authentication header to each request") .help_heading("HTTP options") .num_args(1) } pub fn user_agent() -> clap::Arg { clap::Arg::new("user_agent") .long("user-agent") .short('A') .value_name("NAME") .help("Specify the User-Agent string to send to the HTTP server") .help_heading("HTTP options") .num_args(1) } pub fn variable() -> clap::Arg { clap::Arg::new("variable") .long("variable") .value_name("NAME=VALUE") .help("Define a variable") .help_heading("Run options") .num_args(1) .action(clap::ArgAction::Append) } pub fn variables_file() -> clap::Arg { clap::Arg::new("variables_file") .long("variables-file") .value_name("FILE") .help("Define a properties file in which you define your variables") .help_heading("Run options") .num_args(1) .action(clap::ArgAction::Append) } pub fn verbose() -> clap::Arg { clap::Arg::new("verbose") .long("verbose") .short('v') .help("Turn on verbose") .help_heading("Output options") .action(clap::ArgAction::SetTrue) } pub fn very_verbose() -> clap::Arg { clap::Arg::new("very_verbose") .long("very-verbose") .help("Turn on verbose output, including HTTP response and libcurl logs") .help_heading("Output options") .action(clap::ArgAction::SetTrue) } hurl-7.1.0/src/cli/options/context.rs000064400000000000000000000237421046102023000156760ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::collections::HashMap; /// Represents the context in which is executed Hurl: the env variables, whether standard /// input is a terminal or not (when pipe or redirected to a file for instance), whether standard /// error is a terminal or not, whether Hurl is executed in a CI/CD environment, whether users has /// disallowed ANSI code color etc... pub struct RunContext { /// Are we allowed to ise ANSI escaoe codes or not. with_color: bool, /// All the environment variables. env_vars: HashMap, /// Whether we're running in a Continuous Integration environment or not. ci: bool, /// Is standard input a terminal or not? stdin_term: bool, /// Is standard output a terminal or not? stdout_term: bool, /// Is standard error a terminal or not? stderr_term: bool, } const LEGACY_VARIABLE_PREFIX: &str = "HURL_"; const VARIABLE_PREFIX: &str = "HURL_VARIABLE_"; const SECRET_PREFIX: &str = "HURL_SECRET_"; impl RunContext { /// Creates a new context. The environment is captured and will be seen as non-mutable for the /// execution with this context. pub fn new( env_vars: HashMap, stdin_term: bool, stdout_term: bool, stderr_term: bool, ) -> Self { // Code borrowed from let ci = env_vars.contains_key("CI") || env_vars.contains_key("TF_BUILD"); // According to the NO_COLOR spec, any presence of the variable should disable color, but to // maintain backward compatibility with code < 7.1.0, we check that the NO_COLOR env is at // least not empty. let with_color = if let Some(v) = env_vars.get("NO_COLOR") { if !v.is_empty() { false } else { stdout_term } } else { stdout_term }; RunContext { with_color, env_vars, ci, stdin_term, stdout_term, stderr_term, } } /// Returns `true` if ANSI escape codes are authorized, `false` otherwise. pub fn is_with_color(&self) -> bool { self.with_color } /// Returns the map of Hurl variables injected by environment variables. /// /// Environment variables are prefixed with `HURL_VARIABLE_` and returned values have their name /// stripped of this prefix. pub fn var_env_vars(&self) -> HashMap<&str, &str> { self.env_vars .iter() .filter_map(|(name, value)| { name.strip_prefix(VARIABLE_PREFIX) .filter(|n| !n.is_empty()) .map(|stripped| (stripped, value.as_str())) }) .collect() } /// Returns the map of legacy Hurl variables injected by environment variables. /// /// Environment variables are prefixed with `HURL_` and returned values have their name /// stripped of this prefix. pub fn legacy_var_env_vars(&self) -> HashMap<&str, &str> { self.env_vars .iter() .filter_map(|(name, value)| { name.strip_prefix(LEGACY_VARIABLE_PREFIX) // Not a new variable .filter(|_| !name.starts_with(VARIABLE_PREFIX)) // Not a secret .filter(|_| !name.starts_with(SECRET_PREFIX)) .filter(|n| !n.is_empty()) .map(|stripped| (stripped, value.as_str())) }) .collect() } /// Returns the map of Hurl secrets injected by environment variables. /// /// Environment variables are prefixed with `HURL_SECRET_` and returned values have their name /// stripped of this prefix. pub fn secret_env_vars(&self) -> HashMap<&str, &str> { self.env_vars .iter() .filter_map(|(name, value)| { name.strip_prefix(SECRET_PREFIX) .filter(|n| !n.is_empty()) .map(|stripped| (stripped, value.as_str())) }) .collect() } /// Returns `true` if the context is run from a CI context (like GitHub Actions, GitLab CI/CD etc...) /// `false` otherwise. pub fn is_ci(&self) -> bool { self.ci } /// Checks if standard input is a terminal. pub fn is_stdin_term(&self) -> bool { self.stdin_term } /// Checks if standard output is a terminal. pub fn is_stdout_term(&self) -> bool { self.stdout_term } /// Checks if standard error is a terminal. pub fn is_stderr_term(&self) -> bool { self.stderr_term } } #[cfg(test)] mod tests { use crate::cli::options::context::RunContext; use std::collections::HashMap; #[test] fn context_is_colored() { let stdin_term = true; let stdout_term = true; let stderr_term = true; let env_vars = HashMap::from([("A".to_string(), "B".to_string())]); let ctx = RunContext::new(env_vars, stdin_term, stdout_term, stderr_term); assert!(ctx.is_with_color()); } #[test] fn context_respect_no_color() { let stdin_term = true; let stdout_term = true; let stderr_term = true; let env_vars = HashMap::from([("NO_COLOR".to_string(), "1".to_string())]); let ctx = RunContext::new(env_vars, stdin_term, stdout_term, stderr_term); assert!(!ctx.is_with_color()); } #[test] fn empty_variables_secrets_from_env() { let stdin_term = true; let stdout_term = true; let stderr_term = true; let env_vars = HashMap::from([ ("FOO".to_string(), "xxx".to_string()), ("BAR".to_string(), "yyy".to_string()), ("BAZ".to_string(), "yyy".to_string()), ]); let ctx = RunContext::new(env_vars, stdin_term, stdout_term, stderr_term); assert!(ctx.var_env_vars().is_empty()); assert!(ctx.legacy_var_env_vars().is_empty()); assert!(ctx.secret_env_vars().is_empty()); } #[test] fn variables_from_env() { let stdin_term = true; let stdout_term = true; let stderr_term = true; let env_vars = HashMap::from([ ("FOO".to_string(), "xxx".to_string()), ("BAR".to_string(), "yyy".to_string()), ("BAZ".to_string(), "yyy".to_string()), ("HURL_VARIABLE_foo".to_string(), "true".to_string()), ("HURL_VARIABLE_id".to_string(), "1234".to_string()), ("BAZ".to_string(), "yyy".to_string()), ("HURL_VARIABLE".to_string(), "1234".to_string()), ("HURL_VARIABLE_".to_string(), "abcd".to_string()), ("HURL_VARIABLE_FOO".to_string(), "def".to_string()), ]); let ctx = RunContext::new(env_vars, stdin_term, stdout_term, stderr_term); assert_eq!(ctx.var_env_vars().len(), 3); assert_eq!(ctx.var_env_vars()["foo"], "true"); assert_eq!(ctx.var_env_vars()["id"], "1234"); assert_eq!(ctx.var_env_vars()["FOO"], "def"); assert_eq!(ctx.legacy_var_env_vars().len(), 1); assert_eq!(ctx.legacy_var_env_vars()["VARIABLE"], "1234"); assert!(ctx.secret_env_vars().is_empty()); } #[test] fn legacy_variables_from_env() { let stdin_term = true; let stdout_term = true; let stderr_term = true; let env_vars = HashMap::from([ ("FOO".to_string(), "xxx".to_string()), ("BAR".to_string(), "yyy".to_string()), ("BAZ".to_string(), "yyy".to_string()), ("HURL_VARIABLE_bar".to_string(), "def".to_string()), ("HURL_foo".to_string(), "true".to_string()), ("HURL_id".to_string(), "1234".to_string()), ("BAZ".to_string(), "yyy".to_string()), ("HURL_".to_string(), "1234".to_string()), ("HURL_".to_string(), "abcd".to_string()), ("HURL_FOO".to_string(), "def".to_string()), ]); let ctx = RunContext::new(env_vars, stdin_term, stdout_term, stderr_term); assert_eq!(ctx.var_env_vars().len(), 1); assert_eq!(ctx.var_env_vars()["bar"], "def"); assert_eq!(ctx.legacy_var_env_vars().len(), 3); assert_eq!(ctx.legacy_var_env_vars()["foo"], "true"); assert_eq!(ctx.legacy_var_env_vars()["id"], "1234"); assert_eq!(ctx.legacy_var_env_vars()["FOO"], "def"); assert!(ctx.secret_env_vars().is_empty()); } #[test] fn legacy_secrets_from_env() { let stdin_term = true; let stdout_term = true; let stderr_term = true; let env_vars = HashMap::from([ ("FOO".to_string(), "xxx".to_string()), ("HURL_SECRET".to_string(), "48".to_string()), ("HURL_SECRET_".to_string(), "48".to_string()), ("HURL_SECRET_abcd".to_string(), "1234".to_string()), ("HURL_SECRET_ABCD".to_string(), "5678".to_string()), ("BAR".to_string(), "bar".to_string()), ]); let ctx = RunContext::new(env_vars, stdin_term, stdout_term, stderr_term); assert!(ctx.var_env_vars().is_empty()); assert_eq!(ctx.legacy_var_env_vars().len(), 1); assert_eq!(ctx.legacy_var_env_vars()["SECRET"], "48"); assert_eq!(ctx.secret_env_vars().len(), 2); assert_eq!(ctx.secret_env_vars()["abcd"], "1234"); assert_eq!(ctx.secret_env_vars()["ABCD"], "5678"); } } hurl-7.1.0/src/cli/options/duration.rs000064400000000000000000000056641046102023000160420ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::str::FromStr; use hurl_core::ast::U64; use hurl_core::types::{Duration, DurationUnit, ToSource}; use regex::Regex; /// Parses a string to a `Duration`, including time unit. /// /// Example: `32s`, `10m`, `20000`. /// pub fn parse(duration: &str) -> Result { let re = Regex::new(r"^(\d+)([a-zA-Z]*)$").unwrap(); if let Some(caps) = re.captures(duration) { let source = caps.get(1).unwrap().as_str().to_string(); let value = source.parse::().unwrap(); let unit = caps.get(2).unwrap().as_str(); let unit = if unit.is_empty() { None } else { Some(DurationUnit::from_str(unit)?) }; let value = U64::new(value, source.to_source()); Ok(Duration { value, unit }) } else { Err("Invalid duration".to_string()) } } #[cfg(test)] mod tests { use super::*; #[test] pub fn test_parse_error() { assert_eq!(parse("").unwrap_err(), "Invalid duration".to_string()); assert_eq!(parse("s").unwrap_err(), "Invalid duration".to_string()); assert_eq!(parse("10s10").unwrap_err(), "Invalid duration".to_string()); assert_eq!( parse("10mm").unwrap_err(), "Invalid duration unit mm".to_string() ); } #[test] pub fn test_parse() { assert_eq!( parse("10").unwrap(), Duration { value: U64::new(10, "10".to_source()), unit: None } ); assert_eq!( parse("10s").unwrap(), Duration { value: U64::new(10, "10".to_source()), unit: Some(DurationUnit::Second) } ); assert_eq!( parse("10000ms").unwrap(), Duration { value: U64::new(10000, "10000".to_source()), unit: Some(DurationUnit::MilliSecond) } ); assert_eq!( parse("5m").unwrap(), Duration { value: U64::new(5, "5".to_source()), unit: Some(DurationUnit::Minute) } ); assert_eq!( parse("3h").unwrap(), Duration { value: U64::new(3, "3".to_source()), unit: Some(DurationUnit::Hour) } ); } } hurl-7.1.0/src/cli/options/error.rs000064400000000000000000000047371046102023000153460ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::fmt; use std::path::PathBuf; #[derive(Clone, Debug, PartialEq, Eq)] pub enum CliOptionsError { DisplayHelp(String), DisplayVersion(String), NoInput(String), Error(String), InvalidInputFile(PathBuf), } impl CliOptionsError { /// Converts a clap error to an instance of [`CliOptionsError`]. pub fn from_clap(error: clap::Error, allow_color: bool) -> Self { match error.kind() { clap::error::ErrorKind::DisplayVersion => { CliOptionsError::DisplayVersion(error.to_string()) } clap::error::ErrorKind::DisplayHelp => { let help = if allow_color { error.render().ansi().to_string() } else { error.to_string() }; CliOptionsError::DisplayHelp(help) } _ => { // Other clap errors are prefixed with "error ", we strip this prefix as we want to // have our own error prefix. let message = error.to_string(); let message = message.strip_prefix("error: ").unwrap_or(&message); CliOptionsError::Error(message.to_string()) } } } } impl fmt::Display for CliOptionsError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { CliOptionsError::DisplayHelp(message) => write!(f, "{message}"), CliOptionsError::DisplayVersion(message) => write!(f, "{message}"), CliOptionsError::NoInput(message) => write!(f, "{message}"), CliOptionsError::Error(message) => write!(f, "error: {message}"), CliOptionsError::InvalidInputFile(path) => write!( f, "error: Cannot access '{}': No such file or directory", path.display() ), } } } hurl-7.1.0/src/cli/options/matches.rs000064400000000000000000000512331046102023000156320ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use clap::ArgMatches; use hurl::pretty::PrettyMode; use hurl::runner::Value; use hurl_core::input::Input; use hurl_core::types::{BytesPerSec, Count, DurationUnit}; use super::context::RunContext; use super::variables::TypeKind; use super::variables_file::VariablesFile; use super::{duration, variables, CliOptionsError, ErrorFormat, HttpVersion, IpResolve, Output}; use crate::cli::OutputType; pub fn cacert_file(arg_matches: &ArgMatches) -> Result, CliOptionsError> { match get_string(arg_matches, "cacert_file") { None => Ok(None), Some(filename) => { let path = Path::new(&filename); if path.exists() { Ok(Some(filename)) } else { Err(CliOptionsError::Error(format!( "Input file {} does not exist", path.display() ))) } } } } pub fn aws_sigv4(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "aws_sigv4") } pub fn client_cert_file(arg_matches: &ArgMatches) -> Result, CliOptionsError> { match get::(arg_matches, "client_cert_file") { None => Ok(None), Some(filename) => { if !Path::new(&filename).is_file() { let message = format!("File {filename} does not exist"); Err(CliOptionsError::Error(message)) } else { Ok(Some(filename)) } } } } pub fn client_key_file(arg_matches: &ArgMatches) -> Result, CliOptionsError> { match get::(arg_matches, "client_key_file") { None => Ok(None), Some(filename) => { if !Path::new(&filename).is_file() { let message = format!("File {filename} does not exist"); Err(CliOptionsError::Error(message)) } else { Ok(Some(filename)) } } } } /// Returns true if Hurl output uses ANSI code and false otherwise. /// /// If it has no flags, we use the run `context` to determine if we use color or not. pub fn color(arg_matches: &ArgMatches, context: &RunContext) -> bool { if has_flag(arg_matches, "color") { return true; } if has_flag(arg_matches, "no_color") { return false; } context.is_with_color() } pub fn compressed(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "compressed") } pub fn connect_timeout(arg_matches: &ArgMatches) -> Result { let s = get::(arg_matches, "connect_timeout").unwrap_or("300".to_string()); get_duration(&s, DurationUnit::Second) } pub fn connects_to(arg_matches: &ArgMatches) -> Vec { get_strings(arg_matches, "connect_to").unwrap_or_default() } pub fn continue_on_error(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "continue_on_error") } pub fn cookie_input_file(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "cookies_input_file") } pub fn cookie_output_file(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "cookies_output_file").map(PathBuf::from) } pub fn curl_file(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "curl").map(PathBuf::from) } pub fn delay(arg_matches: &ArgMatches) -> Result { let s = get::(arg_matches, "delay").unwrap_or("0".to_string()); get_duration(&s, DurationUnit::MilliSecond) } pub fn error_format(arg_matches: &ArgMatches) -> ErrorFormat { let error_format = get::(arg_matches, "error_format").unwrap_or("short".to_string()); match error_format.as_str() { "long" => ErrorFormat::Long, "short" => ErrorFormat::Short, _ => ErrorFormat::Short, } } pub fn file_root(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "file_root") } pub fn follow_location(arg_matches: &ArgMatches) -> (bool, bool) { let follow_location = has_flag(arg_matches, "follow_location") || has_flag(arg_matches, "follow_location_trusted"); let follow_location_trusted = has_flag(arg_matches, "follow_location_trusted"); (follow_location, follow_location_trusted) } pub fn from_entry(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "from_entry").map(|x| x as usize) } pub fn headers(arg_matches: &ArgMatches) -> Vec { get_strings(arg_matches, "header").unwrap_or_default() } pub fn html_dir(arg_matches: &ArgMatches) -> Result, CliOptionsError> { if let Some(dir) = get::(arg_matches, "report_html") { let path = Path::new(&dir); if !path.exists() { match fs::create_dir_all(path) { Err(_) => Err(CliOptionsError::Error(format!( "HTML dir {} can not be created", path.display() ))), Ok(_) => Ok(Some(path.to_path_buf())), } } else if path.is_dir() { Ok(Some(path.to_path_buf())) } else { Err(CliOptionsError::Error(format!( "{} is not a valid directory", path.display() ))) } } else { Ok(None) } } pub fn http_version(arg_matches: &ArgMatches) -> Option { if has_flag(arg_matches, "http3") { Some(HttpVersion::V3) } else if has_flag(arg_matches, "http2") { Some(HttpVersion::V2) } else if has_flag(arg_matches, "http11") { Some(HttpVersion::V11) } else if has_flag(arg_matches, "http10") { Some(HttpVersion::V10) } else { None } } pub fn ignore_asserts(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "ignore_asserts") } pub fn include(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "include") } /// Returns true if we have at least one input files. /// The input file can be a file, the standard input, or a glob (even a glob returns empty results). pub fn has_input_files(arg_matches: &ArgMatches, context: &RunContext) -> bool { get_strings(arg_matches, "input_files").is_some() || get_strings(arg_matches, "glob").is_some() || !context.is_stdin_term() } /// Returns the input files from the positional arguments and the glob options pub fn input_files( arg_matches: &ArgMatches, context: &RunContext, ) -> Result, CliOptionsError> { let mut files = vec![]; if let Some(filenames) = get_strings(arg_matches, "input_files") { for filename in &filenames { let filename = Path::new(filename); if !filename.exists() { return Err(CliOptionsError::InvalidInputFile(filename.to_path_buf())); } if filename.is_file() { let file = Input::from(filename); files.push(file); } else if filename.is_dir() { walks_hurl_files(filename, &mut files)?; } } } for filename in glob_files(arg_matches)? { files.push(filename); } if files.is_empty() && !context.is_stdin_term() { let input = match Input::from_stdin() { Ok(input) => input, Err(err) => return Err(CliOptionsError::Error(err.to_string())), }; files.push(input); } Ok(files) } /// Walks recursively a directory from `dir` and push Hurl files to `files`. fn walks_hurl_files(dir: &Path, files: &mut Vec) -> Result<(), CliOptionsError> { let Ok(entries) = fs::read_dir(dir) else { return Err(CliOptionsError::InvalidInputFile(dir.to_path_buf())); }; for entry in entries { let Ok(entry) = entry else { return Err(CliOptionsError::InvalidInputFile(dir.to_path_buf())); }; let path = entry.path(); if path.is_dir() { walks_hurl_files(&path, files)?; } else if entry.path().extension() == Some("hurl".as_ref()) { files.push(Input::from(entry.path())); } } Ok(()) } pub fn insecure(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "insecure") } pub fn interactive(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "interactive") } pub fn ip_resolve(arg_matches: &ArgMatches) -> Option { if has_flag(arg_matches, "ipv6") { Some(IpResolve::IpV6) } else if has_flag(arg_matches, "ipv4") { Some(IpResolve::IpV4) } else { None } } pub fn junit_file(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "report_junit").map(PathBuf::from) } pub fn limit_rate(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "limit_rate").map(BytesPerSec) } pub fn max_filesize(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "max_filesize") } pub fn max_redirect(arg_matches: &ArgMatches) -> Count { match get::(arg_matches, "max_redirects").unwrap_or(50) { -1 => Count::Infinite, m => Count::Finite(m as usize), } } pub fn jobs(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "jobs").map(|m| m as usize) } pub fn json_report_dir(arg_matches: &ArgMatches) -> Result, CliOptionsError> { if let Some(dir) = get::(arg_matches, "report_json") { let path = Path::new(&dir); if !path.exists() { match fs::create_dir_all(path) { Err(_) => Err(CliOptionsError::Error(format!( "JSON dir {} can not be created", path.display() ))), Ok(_) => Ok(Some(path.to_path_buf())), } } else if path.is_dir() { Ok(Some(path.to_path_buf())) } else { Err(CliOptionsError::Error(format!( "{} is not a valid directory", path.display() ))) } } else { Ok(None) } } pub fn negotiate(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "negotiate") } pub fn netrc(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "netrc") } pub fn netrc_file(arg_matches: &ArgMatches) -> Result, CliOptionsError> { match get::(arg_matches, "netrc_file") { None => Ok(None), Some(filename) => { if !Path::new(&filename).is_file() { let message = format!("File {filename} does not exist"); Err(CliOptionsError::Error(message)) } else { Ok(Some(filename)) } } } } pub fn netrc_optional(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "netrc_optional") } pub fn no_proxy(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "noproxy") } pub fn ntlm(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "ntlm") } pub fn output(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "output").map(|filename| Output::new(&filename)) } pub fn output_type(arg_matches: &ArgMatches) -> OutputType { if has_flag(arg_matches, "json") { OutputType::Json } else if has_flag(arg_matches, "no_output") || test(arg_matches) { OutputType::NoOutput } else { OutputType::ResponseBody } } pub fn parallel(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "parallel") || has_flag(arg_matches, "test") } pub fn path_as_is(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "path_as_is") } pub fn pinned_pub_key(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "pinned_pub_key") } pub fn pretty(arg_matches: &ArgMatches, context: &RunContext) -> PrettyMode { if has_flag(arg_matches, "pretty") { return PrettyMode::Force; } if has_flag(arg_matches, "no_pretty") { return PrettyMode::None; } if context.is_stdout_term() { PrettyMode::Automatic } else { PrettyMode::None } } pub fn progress_bar(arg_matches: &ArgMatches, context: &RunContext) -> bool { // The test progress bar is displayed only for in test mode, for interactive TTYs. // It can be forced by `--progress-bar` option. if !test(arg_matches) { return false; } if has_flag(arg_matches, "progress_bar") { return true; } context.is_stderr_term() && !context.is_ci() } pub fn proxy(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "proxy") } pub fn repeat(arg_matches: &ArgMatches) -> Option { match get::(arg_matches, "repeat") { Some(-1) => Some(Count::Infinite), Some(n) => Some(Count::Finite(n as usize)), None => None, } } pub fn resolves(arg_matches: &ArgMatches) -> Vec { get_strings(arg_matches, "resolve").unwrap_or_default() } pub fn retry(arg_matches: &ArgMatches) -> Option { match get::(arg_matches, "retry") { Some(-1) => Some(Count::Infinite), Some(r) => Some(Count::Finite(r as usize)), None => None, } } pub fn retry_interval(arg_matches: &ArgMatches) -> Result { let s = get::(arg_matches, "retry_interval").unwrap_or("1000".to_string()); get_duration(&s, DurationUnit::MilliSecond) } pub fn secret( matches: &ArgMatches, context: &RunContext, ) -> Result, CliOptionsError> { let mut all_secrets = HashMap::new(); // Secrets are always parsed as string. let type_kind = TypeKind::String; // Insert environment secrets `HURL_SECRET_foo` for (env_name, env_value) in context.secret_env_vars() { let value = variables::parse_value(env_value, type_kind)?; add_secret(&mut all_secrets, env_name.to_string(), value)?; } // Add secrets from files: if let Some(filenames) = get_strings(matches, "secrets_file") { for f in &filenames { let filename = Path::new(f); let vars = VariablesFile::open(filename, type_kind)?; for var in vars { let (name, value) = var?; add_secret(&mut all_secrets, name, value)?; } } } // Finally, add single secrets. if let Some(secrets) = get_strings(matches, "secret") { for s in secrets { let (name, value) = variables::parse(&s, type_kind)?; add_secret(&mut all_secrets, name, value)?; } } Ok(all_secrets) } /// Add a secret with `name` and `value` to the `secrets` hash map. fn add_secret( secrets: &mut HashMap, name: String, value: Value, ) -> Result<(), CliOptionsError> { // We check that there is no existing secrets if secrets.contains_key(&name) { return Err(CliOptionsError::Error(format!( "secret '{}' can't be reassigned", &name ))); } // Secrets can only be string. if let Value::String(value) = value { secrets.insert(name.to_string(), value); } Ok(()) } pub fn ssl_no_revoke(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "ssl_no_revoke") } pub fn tap_file(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "report_tap").map(PathBuf::from) } pub fn test(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "test") } pub fn timeout(arg_matches: &ArgMatches) -> Result { let s = get::(arg_matches, "max_time").unwrap_or("300".to_string()); get_duration(&s, DurationUnit::Second) } pub fn to_entry(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "to_entry").map(|x| x as usize) } pub fn unix_socket(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "unix_socket") } pub fn user(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "user") } pub fn user_agent(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "user_agent") } /// Returns a map of variables from the command line options `matches`. pub fn variables( matches: &ArgMatches, context: &RunContext, ) -> Result, CliOptionsError> { let mut variables = HashMap::new(); // Variables are typed, based on their values. let type_kind = TypeKind::Inferred; // Insert environment variables `HURL_VARIABLE_foo` for (env_name, env_value) in context.var_env_vars() { let value = variables::parse_value(env_value, type_kind)?; variables.insert(env_name.to_string(), value); } // Insert legacy environment variables `HURL_foo` for (env_name, env_value) in context.legacy_var_env_vars() { let value = variables::parse_value(env_value, type_kind)?; variables.insert(env_name.to_string(), value); } // Then add variables from files: if let Some(filenames) = get_strings(matches, "variables_file") { for f in &filenames { let filename = Path::new(f); let vars = VariablesFile::open(filename, type_kind)?; for var in vars { let (name, value) = var?; variables.insert(name.to_string(), value); } } } // Finally, add single variables from command line. if let Some(input) = get_strings(matches, "variable") { for s in input { let (name, value) = variables::parse(&s, type_kind)?; variables.insert(name.to_string(), value); } } Ok(variables) } pub fn verbose(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "verbose") } pub fn very_verbose(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "very_verbose") } /// Returns a list of path names from the command line options `matches`. fn glob_files(matches: &ArgMatches) -> Result, CliOptionsError> { let mut all_files = vec![]; if let Some(exprs) = get_strings(matches, "glob") { for expr in exprs { let paths = match glob::glob(&expr) { Ok(paths) => paths, Err(_) => { return Err(CliOptionsError::Error( "Failed to read glob pattern".to_string(), )) } }; let mut files = vec![]; for entry in paths { match entry { Ok(path) => files.push(Input::from(path)), Err(_) => { return Err(CliOptionsError::Error( "Failed to read glob pattern".to_string(), )) } } } if files.is_empty() { return Err(CliOptionsError::InvalidInputFile(PathBuf::from(&expr))); } all_files.extend(files); } } Ok(all_files) } /// Returns an optional value of type `T` from the command line `matches` given the option `name`. fn get(matches: &ArgMatches, name: &str) -> Option { matches.get_one::(name).cloned() } fn has_flag(matches: &ArgMatches, name: &str) -> bool { matches.get_one::(name) == Some(&true) } fn get_string(matches: &ArgMatches, name: &str) -> Option { matches.get_one::(name).map(|x| x.to_string()) } /// Returns an optional list of `String` from the command line `matches` given the option `name`. pub fn get_strings(matches: &ArgMatches, name: &str) -> Option> { matches .get_many::(name) .map(|v| v.map(|x| x.to_string()).collect()) } /// Get duration from input string `s` and `default_unit` fn get_duration(s: &str, default_unit: DurationUnit) -> Result { let duration = duration::parse(s).map_err(CliOptionsError::Error)?; let unit = duration.unit.unwrap_or(default_unit); let millis = match unit { DurationUnit::MilliSecond => duration.value.as_u64(), DurationUnit::Second => duration.value.as_u64() * 1000, DurationUnit::Minute => duration.value.as_u64() * 1000 * 60, DurationUnit::Hour => duration.value.as_u64() * 1000 * 60 * 60, }; Ok(Duration::from_millis(millis)) } hurl-7.1.0/src/cli/options/mod.rs000064400000000000000000000471231046102023000147700ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ mod commands; mod context; mod duration; mod error; mod matches; mod variables; mod variables_file; use std::collections::HashMap; use std::env; use std::path::{Path, PathBuf}; use std::time::Duration; use clap::builder::styling::{AnsiColor, Effects}; use clap::builder::Styles; use clap::ArgMatches; pub use error::CliOptionsError; use hurl::http; use hurl::http::RequestedHttpVersion; use hurl::pretty::PrettyMode; use hurl::runner::Output; use hurl::util::logger::{LoggerOptions, LoggerOptionsBuilder, Verbosity}; use hurl::util::path::ContextDir; use hurl_core::ast::Entry; use hurl_core::input::{Input, InputKind}; use hurl_core::types::{BytesPerSec, Count}; use crate::cli; pub use crate::cli::options::context::RunContext; use crate::runner::{RunnerOptions, RunnerOptionsBuilder, Value}; /// Represents the list of all options that can be used in Hurl command line. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CliOptions { pub aws_sigv4: Option, pub cacert_file: Option, pub client_cert_file: Option, pub client_key_file: Option, pub color: bool, pub compressed: bool, pub connect_timeout: Duration, pub connects_to: Vec, pub continue_on_error: bool, pub cookie_input_file: Option, pub cookie_output_file: Option, pub curl_file: Option, pub delay: Duration, pub error_format: ErrorFormat, pub file_root: Option, pub follow_location: bool, pub follow_location_trusted: bool, pub from_entry: Option, pub headers: Vec, pub html_dir: Option, pub http_version: Option, pub ignore_asserts: bool, pub include: bool, pub input_files: Vec, pub insecure: bool, pub interactive: bool, pub ip_resolve: Option, pub jobs: Option, pub json_report_dir: Option, pub junit_file: Option, pub limit_rate: Option, pub max_filesize: Option, pub max_redirect: Count, pub negotiate: bool, pub netrc: bool, pub netrc_file: Option, pub netrc_optional: bool, pub no_proxy: Option, pub ntlm: bool, pub output: Option, pub output_type: OutputType, pub parallel: bool, pub path_as_is: bool, pub pinned_pub_key: Option, pub pretty: PrettyMode, pub progress_bar: bool, pub proxy: Option, pub repeat: Option, pub resolves: Vec, pub retry: Option, pub retry_interval: Duration, pub secrets: HashMap, pub ssl_no_revoke: bool, pub tap_file: Option, pub test: bool, pub timeout: Duration, pub to_entry: Option, pub unix_socket: Option, pub user: Option, pub user_agent: Option, pub variables: HashMap, pub verbose: bool, pub very_verbose: bool, } /// Error format: long or rich. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum ErrorFormat { Short, Long, } impl From for hurl::util::logger::ErrorFormat { fn from(value: ErrorFormat) -> Self { match value { ErrorFormat::Short => hurl::util::logger::ErrorFormat::Short, ErrorFormat::Long => hurl::util::logger::ErrorFormat::Long, } } } /// Requested HTTP version. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum HttpVersion { V10, V11, V2, V3, } impl From for RequestedHttpVersion { fn from(value: HttpVersion) -> Self { match value { HttpVersion::V10 => RequestedHttpVersion::Http10, HttpVersion::V11 => RequestedHttpVersion::Http11, HttpVersion::V2 => RequestedHttpVersion::Http2, HttpVersion::V3 => RequestedHttpVersion::Http3, } } } /// IP protocol used. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum IpResolve { IpV4, IpV6, } impl From for http::IpResolve { fn from(value: IpResolve) -> Self { match value { IpResolve::IpV4 => http::IpResolve::IpV4, IpResolve::IpV6 => http::IpResolve::IpV6, } } } fn get_version() -> String { let libcurl_version = http::libcurl_version_info(); let pkg_version = env!("CARGO_PKG_VERSION"); format!( "{} ({}) {}\nFeatures (libcurl): {}\nFeatures (built-in): brotli", pkg_version, libcurl_version.host, libcurl_version.libraries.join(" "), libcurl_version.features.join(" ") ) } /// Parse the Hurl CLI options and returns a [`CliOptions`] result, given a run `context` /// (environment variables). pub fn parse(context: &RunContext) -> Result { let styles = Styles::styled() .header(AnsiColor::Green.on_default() | Effects::BOLD) .usage(AnsiColor::Green.on_default() | Effects::BOLD) .literal(AnsiColor::Cyan.on_default() | Effects::BOLD) .placeholder(AnsiColor::Cyan.on_default()); let mut command = clap::Command::new("hurl") .version(get_version()) .disable_colored_help(true) .styles(styles) .about("Hurl, run and test HTTP requests with plain text") // HTTP options .arg(commands::aws_sigv4()) .arg(commands::cacert_file()) .arg(commands::client_cert_file()) .arg(commands::compressed()) .arg(commands::connect_timeout()) .arg(commands::connect_to()) .arg(commands::header()) .arg(commands::http10()) .arg(commands::http11()) .arg(commands::http2()) .arg(commands::http3()) .arg(commands::input_files()) .arg(commands::insecure()) .arg(commands::ipv4()) .arg(commands::ipv6()) .arg(commands::client_key_file()) .arg(commands::limit_rate()) .arg(commands::follow_location()) .arg(commands::follow_location_trusted()) .arg(commands::max_filesize()) .arg(commands::max_redirects()) .arg(commands::max_time()) .arg(commands::negotiate()) .arg(commands::noproxy()) .arg(commands::ntlm()) .arg(commands::path_as_is()) .arg(commands::pinned_pub_key()) .arg(commands::proxy()) .arg(commands::resolve()) .arg(commands::ssl_no_revoke()) .arg(commands::unix_socket()) .arg(commands::user()) .arg(commands::user_agent()) // Output options .arg(commands::color()) .arg(commands::curl()) .arg(commands::error_format()) .arg(commands::include()) .arg(commands::json()) .arg(commands::no_color()) .arg(commands::no_output()) .arg(commands::no_pretty()) .arg(commands::output()) .arg(commands::pretty()) .arg(commands::progress_bar()) .arg(commands::verbose()) .arg(commands::very_verbose()) // Run options .arg(commands::continue_on_error()) .arg(commands::delay()) .arg(commands::from_entry()) .arg(commands::ignore_asserts()) .arg(commands::interactive()) .arg(commands::jobs()) .arg(commands::parallel()) .arg(commands::repeat()) .arg(commands::retry()) .arg(commands::retry_interval()) .arg(commands::secret()) .arg(commands::secrets_file()) .arg(commands::test()) .arg(commands::to_entry()) .arg(commands::variable()) .arg(commands::variables_file()) // Report options .arg(commands::report_html()) .arg(commands::report_json()) .arg(commands::report_junit()) .arg(commands::report_tap()) // Other options .arg(commands::cookies_input_file()) .arg(commands::cookies_output_file()) .arg(commands::file_root()) .arg(commands::glob()) .arg(commands::netrc()) .arg(commands::netrc_file()) .arg(commands::netrc_optional()); let arg_matches = command.try_get_matches_from_mut(env::args_os()); let arg_matches = match arg_matches { Ok(args) => args, Err(error) => return Err(CliOptionsError::from_clap(error, context.is_with_color())), }; // If we've no file input (either from the standard input or from the command line arguments), // we just print help and exit. if !matches::has_input_files(&arg_matches, context) { let help = if context.is_with_color() { command.render_help().ansi().to_string() } else { command.render_help().to_string() }; return Err(CliOptionsError::NoInput(help)); } let opts = parse_matches(&arg_matches, context)?; if opts.input_files.is_empty() { return Err(CliOptionsError::Error( "No input files provided".to_string(), )); } Ok(opts) } fn parse_matches( arg_matches: &ArgMatches, context: &RunContext, ) -> Result { let aws_sigv4 = matches::aws_sigv4(arg_matches); let cacert_file = matches::cacert_file(arg_matches)?; let client_cert_file = matches::client_cert_file(arg_matches)?; let client_key_file = matches::client_key_file(arg_matches)?; let color = matches::color(arg_matches, context); let compressed = matches::compressed(arg_matches); let connect_timeout = matches::connect_timeout(arg_matches)?; let connects_to = matches::connects_to(arg_matches); let continue_on_error = matches::continue_on_error(arg_matches); let cookie_input_file = matches::cookie_input_file(arg_matches); let cookie_output_file = matches::cookie_output_file(arg_matches); let curl_file = matches::curl_file(arg_matches); let delay = matches::delay(arg_matches)?; let error_format = matches::error_format(arg_matches); let file_root = matches::file_root(arg_matches); let (follow_location, follow_location_trusted) = matches::follow_location(arg_matches); let from_entry = matches::from_entry(arg_matches); let headers = matches::headers(arg_matches); let html_dir = matches::html_dir(arg_matches)?; let http_version = matches::http_version(arg_matches); let ignore_asserts = matches::ignore_asserts(arg_matches); let include = matches::include(arg_matches); let input_files = matches::input_files(arg_matches, context)?; let insecure = matches::insecure(arg_matches); let interactive = matches::interactive(arg_matches); let ip_resolve = matches::ip_resolve(arg_matches); let jobs = matches::jobs(arg_matches); let json_report_dir = matches::json_report_dir(arg_matches)?; let junit_file = matches::junit_file(arg_matches); let limit_rate = matches::limit_rate(arg_matches); let max_filesize = matches::max_filesize(arg_matches); let max_redirect = matches::max_redirect(arg_matches); let negotiate = matches::negotiate(arg_matches); let netrc = matches::netrc(arg_matches); let netrc_file = matches::netrc_file(arg_matches)?; let netrc_optional = matches::netrc_optional(arg_matches); let no_proxy = matches::no_proxy(arg_matches); let ntlm = matches::ntlm(arg_matches); let parallel = matches::parallel(arg_matches); let path_as_is = matches::path_as_is(arg_matches); let pinned_pub_key = matches::pinned_pub_key(arg_matches); let progress_bar = matches::progress_bar(arg_matches, context); let pretty = matches::pretty(arg_matches, context); let proxy = matches::proxy(arg_matches); let output = matches::output(arg_matches); let output_type = matches::output_type(arg_matches); let repeat = matches::repeat(arg_matches); let resolves = matches::resolves(arg_matches); let retry = matches::retry(arg_matches); let retry_interval = matches::retry_interval(arg_matches)?; let secrets = matches::secret(arg_matches, context)?; let ssl_no_revoke = matches::ssl_no_revoke(arg_matches); let tap_file = matches::tap_file(arg_matches); let test = matches::test(arg_matches); let timeout = matches::timeout(arg_matches)?; let to_entry = matches::to_entry(arg_matches); let unix_socket = matches::unix_socket(arg_matches); let user = matches::user(arg_matches); let user_agent = matches::user_agent(arg_matches); let variables = matches::variables(arg_matches, context)?; let verbose = matches::verbose(arg_matches); let very_verbose = matches::very_verbose(arg_matches); Ok(CliOptions { aws_sigv4, cacert_file, client_cert_file, client_key_file, color, compressed, connect_timeout, connects_to, continue_on_error, cookie_input_file, cookie_output_file, curl_file, delay, error_format, file_root, follow_location, follow_location_trusted, from_entry, headers, html_dir, http_version, ignore_asserts, include, input_files, insecure, interactive, ip_resolve, json_report_dir, junit_file, limit_rate, max_filesize, max_redirect, negotiate, netrc, netrc_file, netrc_optional, no_proxy, ntlm, path_as_is, pinned_pub_key, parallel, pretty, progress_bar, proxy, output, output_type, repeat, resolves, retry, retry_interval, secrets, ssl_no_revoke, tap_file, test, timeout, to_entry, unix_socket, user, user_agent, variables, verbose, very_verbose, jobs, }) } #[derive(Clone, Debug, PartialEq, Eq)] pub enum OutputType { /// The last HTTP response body of a Hurl file is outputted on standard output. ResponseBody, /// The whole Hurl file run is exported in a structured JSON export on standard output. Json, /// Nothing is outputted on standard output when a Hurl file run is completed. NoOutput, } impl CliOptions { /// Converts this instance of [`CliOptions`] to an instance of [`RunnerOptions`] pub fn to_runner_options(&self, filename: &Input, current_dir: &Path) -> RunnerOptions { let aws_sigv4 = self.aws_sigv4.clone(); let cacert_file = self.cacert_file.clone(); let client_cert_file = self.client_cert_file.clone(); let client_key_file = self.client_key_file.clone(); let compressed = self.compressed; let connect_timeout = self.connect_timeout; let connects_to = self.connects_to.clone(); let file_root = match &self.file_root { Some(f) => Path::new(f), None => match filename.kind() { InputKind::File(path) => path.parent().unwrap(), InputKind::Stdin(_) => current_dir, }, }; let context_dir = ContextDir::new(current_dir, file_root); let continue_on_error = self.continue_on_error; let cookie_input_file = self.cookie_input_file.clone(); let delay = self.delay; let follow_location = self.follow_location; let follow_location_trusted = self.follow_location_trusted; let from_entry = self.from_entry; let headers = &self.headers; let http_version = match self.http_version { Some(version) => version.into(), None => RequestedHttpVersion::default(), }; let ignore_asserts = self.ignore_asserts; let insecure = self.insecure; let ip_resolve = match self.ip_resolve { Some(ip) => ip.into(), None => http::IpResolve::default(), }; let max_filesize = self.max_filesize; // Like curl, we don't differentiate upload and download limit rate, we have // only one option. let max_recv_speed = self.limit_rate; let max_send_speed = self.limit_rate; let max_redirect = self.max_redirect; let netrc = self.netrc; let netrc_file = self.netrc_file.clone(); let netrc_optional = self.netrc_optional; let no_proxy = self.no_proxy.clone(); let output = self.output.clone(); let path_as_is = self.path_as_is; let pinned_pub_key = self.pinned_pub_key.clone(); let post_entry = if self.interactive { Some(cli::interactive::post_entry as fn() -> bool) } else { None }; let pre_entry = if self.interactive { Some(cli::interactive::pre_entry as fn(&Entry) -> bool) } else { None }; let proxy = self.proxy.clone(); let resolves = self.resolves.clone(); let retry = self.retry; let retry_interval = self.retry_interval; let ssl_no_revoke = self.ssl_no_revoke; let negotiate = self.negotiate; let ntlm = self.ntlm; let timeout = self.timeout; let to_entry = self.to_entry; let unix_socket = self.unix_socket.clone(); let user = self.user.clone(); let user_agent = self.user_agent.clone(); RunnerOptionsBuilder::new() .aws_sigv4(aws_sigv4) .cacert_file(cacert_file) .client_cert_file(client_cert_file) .client_key_file(client_key_file) .delay(delay) .compressed(compressed) .connect_timeout(connect_timeout) .connects_to(&connects_to) .continue_on_error(continue_on_error) .context_dir(&context_dir) .cookie_input_file(cookie_input_file) .follow_location(follow_location) .follow_location_trusted(follow_location_trusted) .from_entry(from_entry) .headers(headers) .http_version(http_version) .ignore_asserts(ignore_asserts) .insecure(insecure) .ip_resolve(ip_resolve) .max_filesize(max_filesize) .max_recv_speed(max_recv_speed) .max_redirect(max_redirect) .max_send_speed(max_send_speed) .negotiate(negotiate) .netrc(netrc) .netrc_file(netrc_file) .netrc_optional(netrc_optional) .no_proxy(no_proxy) .ntlm(ntlm) .output(output) .path_as_is(path_as_is) .pinned_pub_key(pinned_pub_key) .post_entry(post_entry) .pre_entry(pre_entry) .proxy(proxy) .resolves(&resolves) .retry(retry) .retry_interval(retry_interval) .ssl_no_revoke(ssl_no_revoke) .timeout(timeout) .to_entry(to_entry) .unix_socket(unix_socket) .user(user) .user_agent(user_agent) .build() } /// Converts this instance of [`ClipOptions`] to an instance of [`LoggerOptions`] pub fn to_logger_options(&self) -> LoggerOptions { let verbosity = Verbosity::from(self.verbose, self.very_verbose); LoggerOptionsBuilder::new() .color(self.color) .error_format(self.error_format.into()) .verbosity(verbosity) .build() } } hurl-7.1.0/src/cli/options/variables.rs000064400000000000000000000145341046102023000161610ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::ast::is_variable_reserved; use super::CliOptionsError; use crate::runner::{Number, Value}; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum TypeKind { /// Variables type inferred from value Inferred, /// Variables are forced to be string String, } /// Parses a string "name=value" as a pair of `String` and `Value`. /// /// If `type_kind` is `TypeKind::Inferred`, value variant is inferred from the `value`, /// for instance `true` is parsed as [`Value::Bool(true)`]. /// If `type_kind` is `TypeKind::String`, value is parsed as [`Value::String`] pub fn parse(s: &str, type_kind: TypeKind) -> Result<(String, Value), CliOptionsError> { match s.find('=') { None => Err(CliOptionsError::Error(format!( "Missing value for variable {s}!" ))), Some(index) => { let (name, value) = s.split_at(index); if is_variable_reserved(name) { return Err(CliOptionsError::Error(format!( "Variable {name} conflicts with the {name} function, use a different name." ))); } let value = parse_value(&value[1..], type_kind)?; Ok((name.to_string(), value)) } } } /// Parses a `value` as a pair of String and Value. /// /// If `inferred` is `true`, value variant is inferred from the `value`, for instance true is parsed as [`Value::Bool(true)`]. pub fn parse_value(s: &str, type_kind: TypeKind) -> Result { if type_kind == TypeKind::String { Ok(Value::String(s.to_string())) } else if s == "true" { Ok(Value::Bool(true)) } else if s == "false" { Ok(Value::Bool(false)) } else if s == "null" { Ok(Value::Null) } else if let Ok(v) = s.parse::() { Ok(Value::Number(Number::Integer(v))) } else if s.chars().all(char::is_numeric) { Ok(Value::Number(Number::BigInteger(s.to_string()))) } else if let Ok(v) = s.parse::() { Ok(Value::Number(Number::Float(v))) } else if let Some(s) = s.strip_prefix('"') { if let Some(s) = s.strip_suffix('"') { Ok(Value::String(s.to_string())) } else { Err(CliOptionsError::Error( "Value should end with a double quote".to_string(), )) } } else { Ok(Value::String(s.to_string())) } } #[cfg(test)] mod tests { use super::{CliOptionsError, *}; #[test] fn test_parse() { assert_eq!( parse("name=Jennifer", TypeKind::Inferred).unwrap(), ("name".to_string(), Value::String("Jennifer".to_string())) ); assert_eq!( parse("female=true", TypeKind::Inferred).unwrap(), ("female".to_string(), Value::Bool(true)) ); assert_eq!( parse("age=30", TypeKind::Inferred).unwrap(), ("age".to_string(), Value::Number(Number::Integer(30))) ); assert_eq!( parse("height=1.7", TypeKind::Inferred).unwrap(), ("height".to_string(), Value::Number(Number::Float(1.7))) ); assert_eq!( parse("id=\"123\"", TypeKind::Inferred).unwrap(), ("id".to_string(), Value::String("123".to_string())) ); assert_eq!( parse("id=9223372036854775808", TypeKind::Inferred).unwrap(), ( "id".to_string(), Value::Number(Number::BigInteger("9223372036854775808".to_string())) ) ); assert_eq!( parse("a_null=null", TypeKind::Inferred).unwrap(), ("a_null".to_string(), Value::Null) ); assert_eq!( parse("a_null=null", TypeKind::String).unwrap(), ("a_null".to_string(), Value::String("null".to_string())) ); } #[test] fn test_parse_error() { assert_eq!( parse("name", TypeKind::Inferred).err().unwrap(), CliOptionsError::Error("Missing value for variable name!".to_string()) ); } #[test] fn test_parse_value() { assert_eq!( parse_value("Jennifer", TypeKind::Inferred).unwrap(), Value::String("Jennifer".to_string()) ); assert_eq!( parse_value("true", TypeKind::Inferred).unwrap(), Value::Bool(true) ); assert_eq!( parse_value("30", TypeKind::Inferred).unwrap(), Value::Number(Number::Integer(30)) ); assert_eq!( parse_value("30", TypeKind::String).unwrap(), Value::String("30".to_string()) ); assert_eq!( parse_value("1.7", TypeKind::Inferred).unwrap(), Value::Number(Number::Float(1.7)) ); assert_eq!( parse_value("1.7", TypeKind::String).unwrap(), Value::String("1.7".to_string()) ); assert_eq!( parse_value("1.0", TypeKind::Inferred).unwrap(), Value::Number(Number::Float(1.0)) ); assert_eq!( parse_value("-1.0", TypeKind::Inferred).unwrap(), Value::Number(Number::Float(-1.0)) ); assert_eq!( parse_value("\"123\"", TypeKind::Inferred).unwrap(), Value::String("123".to_string()) ); assert_eq!( parse_value("\"123\"", TypeKind::String).unwrap(), Value::String("\"123\"".to_string()) ); assert_eq!( parse_value("null", TypeKind::Inferred).unwrap(), Value::Null ); } #[test] fn test_parse_value_error() { assert_eq!( parse_value("\"123", TypeKind::Inferred).err().unwrap(), CliOptionsError::Error("Value should end with a double quote".to_string()) ); } } hurl-7.1.0/src/cli/options/variables_file.rs000064400000000000000000000111641046102023000171540ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::cli::options::variables::TypeKind; use crate::cli::options::{variables, CliOptionsError}; use hurl::runner::Value; use std::fs::File; use std::io::{BufRead, BufReader, Lines}; use std::iter::Enumerate; use std::path::{Path, PathBuf}; /// Represents a variables file, in the form of: /// /// ``` /// var_1=foo /// var_2=bar /// var_3=baz /// ``` /// /// [`VariablesFile`] is an iterator that returns a tuple ([`String`], [`Value`]) on each iteration. pub struct VariablesFile { /// Iterator on this variables file lines. lines: Enumerate>>, /// Path of this variables file. path: PathBuf, /// How do we type variables? type_kind: TypeKind, } impl VariablesFile { /// Opens the variables file at `path`. /// Each variable will be typed: either variable type are inferred from their value, or variable /// are forced to be string. pub fn open(path: &Path, type_kind: TypeKind) -> Result { if !path.exists() { return Err(CliOptionsError::Error(format!( "Variables file {} does not exist", path.display() ))); } let Ok(file) = File::open(path) else { let error = CliOptionsError::Error(format!("Error opening {}", path.display())); return Err(error); }; let lines = BufReader::new(file).lines().enumerate(); Ok(VariablesFile { lines, path: path.to_path_buf(), type_kind, }) } } impl Iterator for VariablesFile { type Item = Result<(String, Value), CliOptionsError>; fn next(&mut self) -> Option { loop { let (index, line) = self.lines.next()?; let line = match line { Ok(s) => s, Err(_) => { let error = CliOptionsError::Error(format!( "Can not parse line {} of {}", index + 1, self.path.display() )); return Some(Err(error)); } }; let line = line.trim(); if line.starts_with('#') || line.is_empty() { continue; } let (name, value) = match variables::parse(line, self.type_kind) { Ok(v) => v, Err(err) => return Some(Err(err)), }; return Some(Ok((name, value))); } } } #[cfg(test)] mod tests { use crate::cli::options::variables_file::{TypeKind, VariablesFile}; use hurl::runner::{Number, Value}; use std::path::PathBuf; use std::{env, fs}; fn temp_file(name: &str) -> PathBuf { let dir = env::temp_dir(); dir.join(name) } #[test] fn test_simple_properties_inferred() { let path = temp_file("file1.env"); let content = r#"foo=bar flag=true id=123 "#; fs::write(&path, content).unwrap(); let file = VariablesFile::open(&path, TypeKind::Inferred).unwrap(); let vars = file.collect::>(); assert_eq!( vars, vec![ Ok(("foo".to_string(), Value::String("bar".to_string()))), Ok(("flag".to_string(), Value::Bool(true))), Ok(("id".to_string(), Value::Number(Number::Integer(123)))), ] ); } #[test] fn test_simple_properties_string() { let path = temp_file("file2.env"); let content = r#"foo=bar # With some comments # bla bla bla flag=true id=123 "#; fs::write(&path, content).unwrap(); let file = VariablesFile::open(&path, TypeKind::String).unwrap(); let vars = file.collect::>(); assert_eq!( vars, vec![ Ok(("foo".to_string(), Value::String("bar".to_string()))), Ok(("flag".to_string(), Value::String("true".to_string()))), Ok(("id".to_string(), Value::String("123".to_string()))), ] ); } } hurl-7.1.0/src/cli/summary.rs000064400000000000000000000126521046102023000142120ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::time::Duration; use crate::HurlRun; /// Returns the text summary of this Hurl `runs`. /// /// This is used in `--test`mode. pub fn summary(runs: &[HurlRun], duration: Duration) -> String { let total_files = runs.len(); let total_requests = requests_count(runs); let duration_in_ms = duration.as_millis() as f64; let requests_rate = 1000.0 * (total_requests as f64) / duration_in_ms; let success_files = runs.iter().filter(|r| r.hurl_result.success).count(); let success_percent = 100.0 * success_files as f32 / total_files as f32; let failed = total_files - success_files; let failed_percent = 100.0 * failed as f32 / total_files as f32; let formatted_duration = format_duration(duration); format!( "--------------------------------------------------------------------------------\n\ Executed files: {total_files}\n\ Executed requests: {total_requests} ({requests_rate:.1}/s)\n\ Succeeded files: {success_files} ({success_percent:.1}%)\n\ Failed files: {failed} ({failed_percent:.1}%)\n\ Duration: {duration_in_ms} ms ({formatted_duration})\n" ) } /// Returns a formatted duration string (h:m:s:ms). fn format_duration(duration: Duration) -> String { let total_ms = duration.as_millis(); let hours = total_ms / 3600000; let minutes = (total_ms % 3600000) / 60000; let seconds = (total_ms % 60000) / 1000; let milliseconds = total_ms % 1000; format!("{}h:{}m:{}s:{}ms", hours, minutes, seconds, milliseconds) } /// Returns the total number of executed HTTP requests in this list of `runs`. fn requests_count(runs: &[HurlRun]) -> usize { // Each entry has a list of calls. Each call is a pair of HTTP request / response // so, for a given entry, the number of executed requests is the number of calls. This count // also the retries. runs.iter() .map(|r| { r.hurl_result .entries .iter() .map(|e| e.calls.len()) .sum::() }) .sum() } #[cfg(test)] pub mod tests { use super::*; use hurl::http::CurlCmd; use hurl::runner::{EntryResult, HurlResult}; use hurl_core::ast::SourceInfo; use hurl_core::input::Input; use hurl_core::reader::Pos; use hurl_core::types::Index; #[test] fn create_run_summary() { fn new_run(success: bool, entries_count: usize) -> HurlRun { let dummy_entry = EntryResult { entry_index: Index::new(1), source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), calls: vec![], captures: vec![], asserts: vec![], errors: vec![], transfer_duration: Duration::from_millis(0), compressed: false, curl_cmd: CurlCmd::default(), }; HurlRun { content: String::new(), filename: Input::new(""), hurl_result: HurlResult { entries: vec![dummy_entry; entries_count], success, ..Default::default() }, } } let runs = vec![new_run(true, 10), new_run(true, 20), new_run(true, 4)]; let duration = Duration::from_millis(128); let s = summary(&runs, duration); assert_eq!( s, "--------------------------------------------------------------------------------\n\ Executed files: 3\n\ Executed requests: 0 (0.0/s)\n\ Succeeded files: 3 (100.0%)\n\ Failed files: 0 (0.0%)\n\ Duration: 128 ms (0h:0m:0s:128ms)\n" ); let runs = vec![new_run(true, 10), new_run(false, 10), new_run(true, 40)]; let duration = Duration::from_millis(200); let s = summary(&runs, duration); assert_eq!( s, "--------------------------------------------------------------------------------\n\ Executed files: 3\n\ Executed requests: 0 (0.0/s)\n\ Succeeded files: 2 (66.7%)\n\ Failed files: 1 (33.3%)\n\ Duration: 200 ms (0h:0m:0s:200ms)\n" ); let runs = vec![new_run(true, 5), new_run(true, 15)]; let duration = Duration::from_millis(3661111); let s = summary(&runs, duration); assert_eq!( s, "--------------------------------------------------------------------------------\n\ Executed files: 2\n\ Executed requests: 0 (0.0/s)\n\ Succeeded files: 2 (100.0%)\n\ Failed files: 0 (0.0%)\n\ Duration: 3661111 ms (1h:1m:1s:111ms)\n" ); } } hurl-7.1.0/src/html/entities.rs000064400000000000000000002024151046102023000145340ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::{collections::HashMap, sync::LazyLock}; // HTML5 named character references // // Generated from and // . // Map HTML5 named character references to the equivalent Unicode character(s). static HTML5_ENTITIES: [(&str, &str); 2231] = [ ("AElig", "\u{C6}"), ("AElig;", "\u{C6}"), ("AMP", "\u{26}"), ("AMP;", "\u{26}"), ("Aacute", "\u{C1}"), ("Aacute;", "\u{C1}"), ("Abreve;", "\u{102}"), ("Acirc", "\u{C2}"), ("Acirc;", "\u{C2}"), ("Acy;", "\u{410}"), ("Afr;", "\u{1D504}"), ("Agrave", "\u{C0}"), ("Agrave;", "\u{C0}"), ("Alpha;", "\u{391}"), ("Amacr;", "\u{100}"), ("And;", "\u{2A53}"), ("Aogon;", "\u{104}"), ("Aopf;", "\u{1D538}"), ("ApplyFunction;", "\u{2061}"), ("Aring", "\u{C5}"), ("Aring;", "\u{C5}"), ("Ascr;", "\u{1D49C}"), ("Assign;", "\u{2254}"), ("Atilde", "\u{C3}"), ("Atilde;", "\u{C3}"), ("Auml", "\u{C4}"), ("Auml;", "\u{C4}"), ("Backslash;", "\u{2216}"), ("Barv;", "\u{2AE7}"), ("Barwed;", "\u{2306}"), ("Bcy;", "\u{411}"), ("Because;", "\u{2235}"), ("Bernoullis;", "\u{212C}"), ("Beta;", "\u{392}"), ("Bfr;", "\u{1D505}"), ("Bopf;", "\u{1D539}"), ("Breve;", "\u{2D8}"), ("Bscr;", "\u{212C}"), ("Bumpeq;", "\u{224E}"), ("CHcy;", "\u{427}"), ("COPY", "\u{A9}"), ("COPY;", "\u{A9}"), ("Cacute;", "\u{106}"), ("Cap;", "\u{22D2}"), ("CapitalDifferentialD;", "\u{2145}"), ("Cayleys;", "\u{212D}"), ("Ccaron;", "\u{10C}"), ("Ccedil", "\u{C7}"), ("Ccedil;", "\u{C7}"), ("Ccirc;", "\u{108}"), ("Cconint;", "\u{2230}"), ("Cdot;", "\u{10A}"), ("Cedilla;", "\u{B8}"), ("CenterDot;", "\u{B7}"), ("Cfr;", "\u{212D}"), ("Chi;", "\u{3A7}"), ("CircleDot;", "\u{2299}"), ("CircleMinus;", "\u{2296}"), ("CirclePlus;", "\u{2295}"), ("CircleTimes;", "\u{2297}"), ("ClockwiseContourIntegral;", "\u{2232}"), ("CloseCurlyDoubleQuote;", "\u{201D}"), ("CloseCurlyQuote;", "\u{2019}"), ("Colon;", "\u{2237}"), ("Colone;", "\u{2A74}"), ("Congruent;", "\u{2261}"), ("Conint;", "\u{222F}"), ("ContourIntegral;", "\u{222E}"), ("Copf;", "\u{2102}"), ("Coproduct;", "\u{2210}"), ("CounterClockwiseContourIntegral;", "\u{2233}"), ("Cross;", "\u{2A2F}"), ("Cscr;", "\u{1D49E}"), ("Cup;", "\u{22D3}"), ("CupCap;", "\u{224D}"), ("DD;", "\u{2145}"), ("DDotrahd;", "\u{2911}"), ("DJcy;", "\u{402}"), ("DScy;", "\u{405}"), ("DZcy;", "\u{40F}"), ("Dagger;", "\u{2021}"), ("Darr;", "\u{21A1}"), ("Dashv;", "\u{2AE4}"), ("Dcaron;", "\u{10E}"), ("Dcy;", "\u{414}"), ("Del;", "\u{2207}"), ("Delta;", "\u{394}"), ("Dfr;", "\u{1D507}"), ("DiacriticalAcute;", "\u{B4}"), ("DiacriticalDot;", "\u{2D9}"), ("DiacriticalDoubleAcute;", "\u{2DD}"), ("DiacriticalGrave;", "\u{60}"), ("DiacriticalTilde;", "\u{2DC}"), ("Diamond;", "\u{22C4}"), ("DifferentialD;", "\u{2146}"), ("Dopf;", "\u{1D53B}"), ("Dot;", "\u{A8}"), ("DotDot;", "\u{20DC}"), ("DotEqual;", "\u{2250}"), ("DoubleContourIntegral;", "\u{222F}"), ("DoubleDot;", "\u{A8}"), ("DoubleDownArrow;", "\u{21D3}"), ("DoubleLeftArrow;", "\u{21D0}"), ("DoubleLeftRightArrow;", "\u{21D4}"), ("DoubleLeftTee;", "\u{2AE4}"), ("DoubleLongLeftArrow;", "\u{27F8}"), ("DoubleLongLeftRightArrow;", "\u{27FA}"), ("DoubleLongRightArrow;", "\u{27F9}"), ("DoubleRightArrow;", "\u{21D2}"), ("DoubleRightTee;", "\u{22A8}"), ("DoubleUpArrow;", "\u{21D1}"), ("DoubleUpDownArrow;", "\u{21D5}"), ("DoubleVerticalBar;", "\u{2225}"), ("DownArrow;", "\u{2193}"), ("DownArrowBar;", "\u{2913}"), ("DownArrowUpArrow;", "\u{21F5}"), ("DownBreve;", "\u{311}"), ("DownLeftRightVector;", "\u{2950}"), ("DownLeftTeeVector;", "\u{295E}"), ("DownLeftVector;", "\u{21BD}"), ("DownLeftVectorBar;", "\u{2956}"), ("DownRightTeeVector;", "\u{295F}"), ("DownRightVector;", "\u{21C1}"), ("DownRightVectorBar;", "\u{2957}"), ("DownTee;", "\u{22A4}"), ("DownTeeArrow;", "\u{21A7}"), ("Downarrow;", "\u{21D3}"), ("Dscr;", "\u{1D49F}"), ("Dstrok;", "\u{110}"), ("ENG;", "\u{14A}"), ("ETH", "\u{D0}"), ("ETH;", "\u{D0}"), ("Eacute", "\u{C9}"), ("Eacute;", "\u{C9}"), ("Ecaron;", "\u{11A}"), ("Ecirc", "\u{CA}"), ("Ecirc;", "\u{CA}"), ("Ecy;", "\u{42D}"), ("Edot;", "\u{116}"), ("Efr;", "\u{1D508}"), ("Egrave", "\u{C8}"), ("Egrave;", "\u{C8}"), ("Element;", "\u{2208}"), ("Emacr;", "\u{112}"), ("EmptySmallSquare;", "\u{25FB}"), ("EmptyVerySmallSquare;", "\u{25AB}"), ("Eogon;", "\u{118}"), ("Eopf;", "\u{1D53C}"), ("Epsilon;", "\u{395}"), ("Equal;", "\u{2A75}"), ("EqualTilde;", "\u{2242}"), ("Equilibrium;", "\u{21CC}"), ("Escr;", "\u{2130}"), ("Esim;", "\u{2A73}"), ("Eta;", "\u{397}"), ("Euml", "\u{CB}"), ("Euml;", "\u{CB}"), ("Exists;", "\u{2203}"), ("ExponentialE;", "\u{2147}"), ("Fcy;", "\u{424}"), ("Ffr;", "\u{1D509}"), ("FilledSmallSquare;", "\u{25FC}"), ("FilledVerySmallSquare;", "\u{25AA}"), ("Fopf;", "\u{1D53D}"), ("ForAll;", "\u{2200}"), ("Fouriertrf;", "\u{2131}"), ("Fscr;", "\u{2131}"), ("GJcy;", "\u{403}"), ("GT", "\u{3E}"), ("GT;", "\u{3E}"), ("Gamma;", "\u{393}"), ("Gammad;", "\u{3DC}"), ("Gbreve;", "\u{11E}"), ("Gcedil;", "\u{122}"), ("Gcirc;", "\u{11C}"), ("Gcy;", "\u{413}"), ("Gdot;", "\u{120}"), ("Gfr;", "\u{1D50A}"), ("Gg;", "\u{22D9}"), ("Gopf;", "\u{1D53E}"), ("GreaterEqual;", "\u{2265}"), ("GreaterEqualLess;", "\u{22DB}"), ("GreaterFullEqual;", "\u{2267}"), ("GreaterGreater;", "\u{2AA2}"), ("GreaterLess;", "\u{2277}"), ("GreaterSlantEqual;", "\u{2A7E}"), ("GreaterTilde;", "\u{2273}"), ("Gscr;", "\u{1D4A2}"), ("Gt;", "\u{226B}"), ("HARDcy;", "\u{42A}"), ("Hacek;", "\u{2C7}"), ("Hat;", "\u{5E}"), ("Hcirc;", "\u{124}"), ("Hfr;", "\u{210C}"), ("HilbertSpace;", "\u{210B}"), ("Hopf;", "\u{210D}"), ("HorizontalLine;", "\u{2500}"), ("Hscr;", "\u{210B}"), ("Hstrok;", "\u{126}"), ("HumpDownHump;", "\u{224E}"), ("HumpEqual;", "\u{224F}"), ("IEcy;", "\u{415}"), ("IJlig;", "\u{132}"), ("IOcy;", "\u{401}"), ("Iacute", "\u{CD}"), ("Iacute;", "\u{CD}"), ("Icirc", "\u{CE}"), ("Icirc;", "\u{CE}"), ("Icy;", "\u{418}"), ("Idot;", "\u{130}"), ("Ifr;", "\u{2111}"), ("Igrave", "\u{CC}"), ("Igrave;", "\u{CC}"), ("Im;", "\u{2111}"), ("Imacr;", "\u{12A}"), ("ImaginaryI;", "\u{2148}"), ("Implies;", "\u{21D2}"), ("Int;", "\u{222C}"), ("Integral;", "\u{222B}"), ("Intersection;", "\u{22C2}"), ("InvisibleComma;", "\u{2063}"), ("InvisibleTimes;", "\u{2062}"), ("Iogon;", "\u{12E}"), ("Iopf;", "\u{1D540}"), ("Iota;", "\u{399}"), ("Iscr;", "\u{2110}"), ("Itilde;", "\u{128}"), ("Iukcy;", "\u{406}"), ("Iuml", "\u{CF}"), ("Iuml;", "\u{CF}"), ("Jcirc;", "\u{134}"), ("Jcy;", "\u{419}"), ("Jfr;", "\u{1D50D}"), ("Jopf;", "\u{1D541}"), ("Jscr;", "\u{1D4A5}"), ("Jsercy;", "\u{408}"), ("Jukcy;", "\u{404}"), ("KHcy;", "\u{425}"), ("KJcy;", "\u{40C}"), ("Kappa;", "\u{39A}"), ("Kcedil;", "\u{136}"), ("Kcy;", "\u{41A}"), ("Kfr;", "\u{1D50E}"), ("Kopf;", "\u{1D542}"), ("Kscr;", "\u{1D4A6}"), ("LJcy;", "\u{409}"), ("LT", "\u{3C}"), ("LT;", "\u{3C}"), ("Lacute;", "\u{139}"), ("Lambda;", "\u{39B}"), ("Lang;", "\u{27EA}"), ("Laplacetrf;", "\u{2112}"), ("Larr;", "\u{219E}"), ("Lcaron;", "\u{13D}"), ("Lcedil;", "\u{13B}"), ("Lcy;", "\u{41B}"), ("LeftAngleBracket;", "\u{27E8}"), ("LeftArrow;", "\u{2190}"), ("LeftArrowBar;", "\u{21E4}"), ("LeftArrowRightArrow;", "\u{21C6}"), ("LeftCeiling;", "\u{2308}"), ("LeftDoubleBracket;", "\u{27E6}"), ("LeftDownTeeVector;", "\u{2961}"), ("LeftDownVector;", "\u{21C3}"), ("LeftDownVectorBar;", "\u{2959}"), ("LeftFloor;", "\u{230A}"), ("LeftRightArrow;", "\u{2194}"), ("LeftRightVector;", "\u{294E}"), ("LeftTee;", "\u{22A3}"), ("LeftTeeArrow;", "\u{21A4}"), ("LeftTeeVector;", "\u{295A}"), ("LeftTriangle;", "\u{22B2}"), ("LeftTriangleBar;", "\u{29CF}"), ("LeftTriangleEqual;", "\u{22B4}"), ("LeftUpDownVector;", "\u{2951}"), ("LeftUpTeeVector;", "\u{2960}"), ("LeftUpVector;", "\u{21BF}"), ("LeftUpVectorBar;", "\u{2958}"), ("LeftVector;", "\u{21BC}"), ("LeftVectorBar;", "\u{2952}"), ("Leftarrow;", "\u{21D0}"), ("Leftrightarrow;", "\u{21D4}"), ("LessEqualGreater;", "\u{22DA}"), ("LessFullEqual;", "\u{2266}"), ("LessGreater;", "\u{2276}"), ("LessLess;", "\u{2AA1}"), ("LessSlantEqual;", "\u{2A7D}"), ("LessTilde;", "\u{2272}"), ("Lfr;", "\u{1D50F}"), ("Ll;", "\u{22D8}"), ("Lleftarrow;", "\u{21DA}"), ("Lmidot;", "\u{13F}"), ("LongLeftArrow;", "\u{27F5}"), ("LongLeftRightArrow;", "\u{27F7}"), ("LongRightArrow;", "\u{27F6}"), ("Longleftarrow;", "\u{27F8}"), ("Longleftrightarrow;", "\u{27FA}"), ("Longrightarrow;", "\u{27F9}"), ("Lopf;", "\u{1D543}"), ("LowerLeftArrow;", "\u{2199}"), ("LowerRightArrow;", "\u{2198}"), ("Lscr;", "\u{2112}"), ("Lsh;", "\u{21B0}"), ("Lstrok;", "\u{141}"), ("Lt;", "\u{226A}"), ("Map;", "\u{2905}"), ("Mcy;", "\u{41C}"), ("MediumSpace;", "\u{205F}"), ("Mellintrf;", "\u{2133}"), ("Mfr;", "\u{1D510}"), ("MinusPlus;", "\u{2213}"), ("Mopf;", "\u{1D544}"), ("Mscr;", "\u{2133}"), ("Mu;", "\u{39C}"), ("NJcy;", "\u{40A}"), ("Nacute;", "\u{143}"), ("Ncaron;", "\u{147}"), ("Ncedil;", "\u{145}"), ("Ncy;", "\u{41D}"), ("NegativeMediumSpace;", "\u{200B}"), ("NegativeThickSpace;", "\u{200B}"), ("NegativeThinSpace;", "\u{200B}"), ("NegativeVeryThinSpace;", "\u{200B}"), ("NestedGreaterGreater;", "\u{226B}"), ("NestedLessLess;", "\u{226A}"), ("NewLine;", "\u{A}"), ("Nfr;", "\u{1D511}"), ("NoBreak;", "\u{2060}"), ("NonBreakingSpace;", "\u{A0}"), ("Nopf;", "\u{2115}"), ("Not;", "\u{2AEC}"), ("NotCongruent;", "\u{2262}"), ("NotCupCap;", "\u{226D}"), ("NotDoubleVerticalBar;", "\u{2226}"), ("NotElement;", "\u{2209}"), ("NotEqual;", "\u{2260}"), ("NotEqualTilde;", "\u{2242}\u{338}"), ("NotExists;", "\u{2204}"), ("NotGreater;", "\u{226F}"), ("NotGreaterEqual;", "\u{2271}"), ("NotGreaterFullEqual;", "\u{2267}\u{338}"), ("NotGreaterGreater;", "\u{226B}\u{338}"), ("NotGreaterLess;", "\u{2279}"), ("NotGreaterSlantEqual;", "\u{2A7E}\u{338}"), ("NotGreaterTilde;", "\u{2275}"), ("NotHumpDownHump;", "\u{224E}\u{338}"), ("NotHumpEqual;", "\u{224F}\u{338}"), ("NotLeftTriangle;", "\u{22EA}"), ("NotLeftTriangleBar;", "\u{29CF}\u{338}"), ("NotLeftTriangleEqual;", "\u{22EC}"), ("NotLess;", "\u{226E}"), ("NotLessEqual;", "\u{2270}"), ("NotLessGreater;", "\u{2278}"), ("NotLessLess;", "\u{226A}\u{338}"), ("NotLessSlantEqual;", "\u{2A7D}\u{338}"), ("NotLessTilde;", "\u{2274}"), ("NotNestedGreaterGreater;", "\u{2AA2}\u{338}"), ("NotNestedLessLess;", "\u{2AA1}\u{338}"), ("NotPrecedes;", "\u{2280}"), ("NotPrecedesEqual;", "\u{2AAF}\u{338}"), ("NotPrecedesSlantEqual;", "\u{22E0}"), ("NotReverseElement;", "\u{220C}"), ("NotRightTriangle;", "\u{22EB}"), ("NotRightTriangleBar;", "\u{29D0}\u{338}"), ("NotRightTriangleEqual;", "\u{22ED}"), ("NotSquareSubset;", "\u{228F}\u{338}"), ("NotSquareSubsetEqual;", "\u{22E2}"), ("NotSquareSuperset;", "\u{2290}\u{338}"), ("NotSquareSupersetEqual;", "\u{22E3}"), ("NotSubset;", "\u{2282}\u{20D2}"), ("NotSubsetEqual;", "\u{2288}"), ("NotSucceeds;", "\u{2281}"), ("NotSucceedsEqual;", "\u{2AB0}\u{338}"), ("NotSucceedsSlantEqual;", "\u{22E1}"), ("NotSucceedsTilde;", "\u{227F}\u{338}"), ("NotSuperset;", "\u{2283}\u{20D2}"), ("NotSupersetEqual;", "\u{2289}"), ("NotTilde;", "\u{2241}"), ("NotTildeEqual;", "\u{2244}"), ("NotTildeFullEqual;", "\u{2247}"), ("NotTildeTilde;", "\u{2249}"), ("NotVerticalBar;", "\u{2224}"), ("Nscr;", "\u{1D4A9}"), ("Ntilde", "\u{D1}"), ("Ntilde;", "\u{D1}"), ("Nu;", "\u{39D}"), ("OElig;", "\u{152}"), ("Oacute", "\u{D3}"), ("Oacute;", "\u{D3}"), ("Ocirc", "\u{D4}"), ("Ocirc;", "\u{D4}"), ("Ocy;", "\u{41E}"), ("Odblac;", "\u{150}"), ("Ofr;", "\u{1D512}"), ("Ograve", "\u{D2}"), ("Ograve;", "\u{D2}"), ("Omacr;", "\u{14C}"), ("Omega;", "\u{3A9}"), ("Omicron;", "\u{39F}"), ("Oopf;", "\u{1D546}"), ("OpenCurlyDoubleQuote;", "\u{201C}"), ("OpenCurlyQuote;", "\u{2018}"), ("Or;", "\u{2A54}"), ("Oscr;", "\u{1D4AA}"), ("Oslash", "\u{D8}"), ("Oslash;", "\u{D8}"), ("Otilde", "\u{D5}"), ("Otilde;", "\u{D5}"), ("Otimes;", "\u{2A37}"), ("Ouml", "\u{D6}"), ("Ouml;", "\u{D6}"), ("OverBar;", "\u{203E}"), ("OverBrace;", "\u{23DE}"), ("OverBracket;", "\u{23B4}"), ("OverParenthesis;", "\u{23DC}"), ("PartialD;", "\u{2202}"), ("Pcy;", "\u{41F}"), ("Pfr;", "\u{1D513}"), ("Phi;", "\u{3A6}"), ("Pi;", "\u{3A0}"), ("PlusMinus;", "\u{B1}"), ("Poincareplane;", "\u{210C}"), ("Popf;", "\u{2119}"), ("Pr;", "\u{2ABB}"), ("Precedes;", "\u{227A}"), ("PrecedesEqual;", "\u{2AAF}"), ("PrecedesSlantEqual;", "\u{227C}"), ("PrecedesTilde;", "\u{227E}"), ("Prime;", "\u{2033}"), ("Product;", "\u{220F}"), ("Proportion;", "\u{2237}"), ("Proportional;", "\u{221D}"), ("Pscr;", "\u{1D4AB}"), ("Psi;", "\u{3A8}"), ("QUOT", "\u{22}"), ("QUOT;", "\u{22}"), ("Qfr;", "\u{1D514}"), ("Qopf;", "\u{211A}"), ("Qscr;", "\u{1D4AC}"), ("RBarr;", "\u{2910}"), ("REG", "\u{AE}"), ("REG;", "\u{AE}"), ("Racute;", "\u{154}"), ("Rang;", "\u{27EB}"), ("Rarr;", "\u{21A0}"), ("Rarrtl;", "\u{2916}"), ("Rcaron;", "\u{158}"), ("Rcedil;", "\u{156}"), ("Rcy;", "\u{420}"), ("Re;", "\u{211C}"), ("ReverseElement;", "\u{220B}"), ("ReverseEquilibrium;", "\u{21CB}"), ("ReverseUpEquilibrium;", "\u{296F}"), ("Rfr;", "\u{211C}"), ("Rho;", "\u{3A1}"), ("RightAngleBracket;", "\u{27E9}"), ("RightArrow;", "\u{2192}"), ("RightArrowBar;", "\u{21E5}"), ("RightArrowLeftArrow;", "\u{21C4}"), ("RightCeiling;", "\u{2309}"), ("RightDoubleBracket;", "\u{27E7}"), ("RightDownTeeVector;", "\u{295D}"), ("RightDownVector;", "\u{21C2}"), ("RightDownVectorBar;", "\u{2955}"), ("RightFloor;", "\u{230B}"), ("RightTee;", "\u{22A2}"), ("RightTeeArrow;", "\u{21A6}"), ("RightTeeVector;", "\u{295B}"), ("RightTriangle;", "\u{22B3}"), ("RightTriangleBar;", "\u{29D0}"), ("RightTriangleEqual;", "\u{22B5}"), ("RightUpDownVector;", "\u{294F}"), ("RightUpTeeVector;", "\u{295C}"), ("RightUpVector;", "\u{21BE}"), ("RightUpVectorBar;", "\u{2954}"), ("RightVector;", "\u{21C0}"), ("RightVectorBar;", "\u{2953}"), ("Rightarrow;", "\u{21D2}"), ("Ropf;", "\u{211D}"), ("RoundImplies;", "\u{2970}"), ("Rrightarrow;", "\u{21DB}"), ("Rscr;", "\u{211B}"), ("Rsh;", "\u{21B1}"), ("RuleDelayed;", "\u{29F4}"), ("SHCHcy;", "\u{429}"), ("SHcy;", "\u{428}"), ("SOFTcy;", "\u{42C}"), ("Sacute;", "\u{15A}"), ("Sc;", "\u{2ABC}"), ("Scaron;", "\u{160}"), ("Scedil;", "\u{15E}"), ("Scirc;", "\u{15C}"), ("Scy;", "\u{421}"), ("Sfr;", "\u{1D516}"), ("ShortDownArrow;", "\u{2193}"), ("ShortLeftArrow;", "\u{2190}"), ("ShortRightArrow;", "\u{2192}"), ("ShortUpArrow;", "\u{2191}"), ("Sigma;", "\u{3A3}"), ("SmallCircle;", "\u{2218}"), ("Sopf;", "\u{1D54A}"), ("Sqrt;", "\u{221A}"), ("Square;", "\u{25A1}"), ("SquareIntersection;", "\u{2293}"), ("SquareSubset;", "\u{228F}"), ("SquareSubsetEqual;", "\u{2291}"), ("SquareSuperset;", "\u{2290}"), ("SquareSupersetEqual;", "\u{2292}"), ("SquareUnion;", "\u{2294}"), ("Sscr;", "\u{1D4AE}"), ("Star;", "\u{22C6}"), ("Sub;", "\u{22D0}"), ("Subset;", "\u{22D0}"), ("SubsetEqual;", "\u{2286}"), ("Succeeds;", "\u{227B}"), ("SucceedsEqual;", "\u{2AB0}"), ("SucceedsSlantEqual;", "\u{227D}"), ("SucceedsTilde;", "\u{227F}"), ("SuchThat;", "\u{220B}"), ("Sum;", "\u{2211}"), ("Sup;", "\u{22D1}"), ("Superset;", "\u{2283}"), ("SupersetEqual;", "\u{2287}"), ("Supset;", "\u{22D1}"), ("THORN", "\u{DE}"), ("THORN;", "\u{DE}"), ("TRADE;", "\u{2122}"), ("TSHcy;", "\u{40B}"), ("TScy;", "\u{426}"), ("Tab;", "\u{9}"), ("Tau;", "\u{3A4}"), ("Tcaron;", "\u{164}"), ("Tcedil;", "\u{162}"), ("Tcy;", "\u{422}"), ("Tfr;", "\u{1D517}"), ("Therefore;", "\u{2234}"), ("Theta;", "\u{398}"), ("ThickSpace;", "\u{205F}\u{200A}"), ("ThinSpace;", "\u{2009}"), ("Tilde;", "\u{223C}"), ("TildeEqual;", "\u{2243}"), ("TildeFullEqual;", "\u{2245}"), ("TildeTilde;", "\u{2248}"), ("Topf;", "\u{1D54B}"), ("TripleDot;", "\u{20DB}"), ("Tscr;", "\u{1D4AF}"), ("Tstrok;", "\u{166}"), ("Uacute", "\u{DA}"), ("Uacute;", "\u{DA}"), ("Uarr;", "\u{219F}"), ("Uarrocir;", "\u{2949}"), ("Ubrcy;", "\u{40E}"), ("Ubreve;", "\u{16C}"), ("Ucirc", "\u{DB}"), ("Ucirc;", "\u{DB}"), ("Ucy;", "\u{423}"), ("Udblac;", "\u{170}"), ("Ufr;", "\u{1D518}"), ("Ugrave", "\u{D9}"), ("Ugrave;", "\u{D9}"), ("Umacr;", "\u{16A}"), ("UnderBar;", "\u{5F}"), ("UnderBrace;", "\u{23DF}"), ("UnderBracket;", "\u{23B5}"), ("UnderParenthesis;", "\u{23DD}"), ("Union;", "\u{22C3}"), ("UnionPlus;", "\u{228E}"), ("Uogon;", "\u{172}"), ("Uopf;", "\u{1D54C}"), ("UpArrow;", "\u{2191}"), ("UpArrowBar;", "\u{2912}"), ("UpArrowDownArrow;", "\u{21C5}"), ("UpDownArrow;", "\u{2195}"), ("UpEquilibrium;", "\u{296E}"), ("UpTee;", "\u{22A5}"), ("UpTeeArrow;", "\u{21A5}"), ("Uparrow;", "\u{21D1}"), ("Updownarrow;", "\u{21D5}"), ("UpperLeftArrow;", "\u{2196}"), ("UpperRightArrow;", "\u{2197}"), ("Upsi;", "\u{3D2}"), ("Upsilon;", "\u{3A5}"), ("Uring;", "\u{16E}"), ("Uscr;", "\u{1D4B0}"), ("Utilde;", "\u{168}"), ("Uuml", "\u{DC}"), ("Uuml;", "\u{DC}"), ("VDash;", "\u{22AB}"), ("Vbar;", "\u{2AEB}"), ("Vcy;", "\u{412}"), ("Vdash;", "\u{22A9}"), ("Vdashl;", "\u{2AE6}"), ("Vee;", "\u{22C1}"), ("Verbar;", "\u{2016}"), ("Vert;", "\u{2016}"), ("VerticalBar;", "\u{2223}"), ("VerticalLine;", "\u{7C}"), ("VerticalSeparator;", "\u{2758}"), ("VerticalTilde;", "\u{2240}"), ("VeryThinSpace;", "\u{200A}"), ("Vfr;", "\u{1D519}"), ("Vopf;", "\u{1D54D}"), ("Vscr;", "\u{1D4B1}"), ("Vvdash;", "\u{22AA}"), ("Wcirc;", "\u{174}"), ("Wedge;", "\u{22C0}"), ("Wfr;", "\u{1D51A}"), ("Wopf;", "\u{1D54E}"), ("Wscr;", "\u{1D4B2}"), ("Xfr;", "\u{1D51B}"), ("Xi;", "\u{39E}"), ("Xopf;", "\u{1D54F}"), ("Xscr;", "\u{1D4B3}"), ("YAcy;", "\u{42F}"), ("YIcy;", "\u{407}"), ("YUcy;", "\u{42E}"), ("Yacute", "\u{DD}"), ("Yacute;", "\u{DD}"), ("Ycirc;", "\u{176}"), ("Ycy;", "\u{42B}"), ("Yfr;", "\u{1D51C}"), ("Yopf;", "\u{1D550}"), ("Yscr;", "\u{1D4B4}"), ("Yuml;", "\u{178}"), ("ZHcy;", "\u{416}"), ("Zacute;", "\u{179}"), ("Zcaron;", "\u{17D}"), ("Zcy;", "\u{417}"), ("Zdot;", "\u{17B}"), ("ZeroWidthSpace;", "\u{200B}"), ("Zeta;", "\u{396}"), ("Zfr;", "\u{2128}"), ("Zopf;", "\u{2124}"), ("Zscr;", "\u{1D4B5}"), ("aacute", "\u{E1}"), ("aacute;", "\u{E1}"), ("abreve;", "\u{103}"), ("ac;", "\u{223E}"), ("acE;", "\u{223E}\u{333}"), ("acd;", "\u{223F}"), ("acirc", "\u{E2}"), ("acirc;", "\u{E2}"), ("acute", "\u{B4}"), ("acute;", "\u{B4}"), ("acy;", "\u{430}"), ("aelig", "\u{E6}"), ("aelig;", "\u{E6}"), ("af;", "\u{2061}"), ("afr;", "\u{1D51E}"), ("agrave", "\u{E0}"), ("agrave;", "\u{E0}"), ("alefsym;", "\u{2135}"), ("aleph;", "\u{2135}"), ("alpha;", "\u{3B1}"), ("amacr;", "\u{101}"), ("amalg;", "\u{2A3F}"), ("amp", "\u{26}"), ("amp;", "\u{26}"), ("and;", "\u{2227}"), ("andand;", "\u{2A55}"), ("andd;", "\u{2A5C}"), ("andslope;", "\u{2A58}"), ("andv;", "\u{2A5A}"), ("ang;", "\u{2220}"), ("ange;", "\u{29A4}"), ("angle;", "\u{2220}"), ("angmsd;", "\u{2221}"), ("angmsdaa;", "\u{29A8}"), ("angmsdab;", "\u{29A9}"), ("angmsdac;", "\u{29AA}"), ("angmsdad;", "\u{29AB}"), ("angmsdae;", "\u{29AC}"), ("angmsdaf;", "\u{29AD}"), ("angmsdag;", "\u{29AE}"), ("angmsdah;", "\u{29AF}"), ("angrt;", "\u{221F}"), ("angrtvb;", "\u{22BE}"), ("angrtvbd;", "\u{299D}"), ("angsph;", "\u{2222}"), ("angst;", "\u{C5}"), ("angzarr;", "\u{237C}"), ("aogon;", "\u{105}"), ("aopf;", "\u{1D552}"), ("ap;", "\u{2248}"), ("apE;", "\u{2A70}"), ("apacir;", "\u{2A6F}"), ("ape;", "\u{224A}"), ("apid;", "\u{224B}"), ("apos;", "\u{27}"), ("approx;", "\u{2248}"), ("approxeq;", "\u{224A}"), ("aring", "\u{E5}"), ("aring;", "\u{E5}"), ("ascr;", "\u{1D4B6}"), ("ast;", "\u{2A}"), ("asymp;", "\u{2248}"), ("asympeq;", "\u{224D}"), ("atilde", "\u{E3}"), ("atilde;", "\u{E3}"), ("auml", "\u{E4}"), ("auml;", "\u{E4}"), ("awconint;", "\u{2233}"), ("awint;", "\u{2A11}"), ("bNot;", "\u{2AED}"), ("backcong;", "\u{224C}"), ("backepsilon;", "\u{3F6}"), ("backprime;", "\u{2035}"), ("backsim;", "\u{223D}"), ("backsimeq;", "\u{22CD}"), ("barvee;", "\u{22BD}"), ("barwed;", "\u{2305}"), ("barwedge;", "\u{2305}"), ("bbrk;", "\u{23B5}"), ("bbrktbrk;", "\u{23B6}"), ("bcong;", "\u{224C}"), ("bcy;", "\u{431}"), ("bdquo;", "\u{201E}"), ("becaus;", "\u{2235}"), ("because;", "\u{2235}"), ("bemptyv;", "\u{29B0}"), ("bepsi;", "\u{3F6}"), ("bernou;", "\u{212C}"), ("beta;", "\u{3B2}"), ("beth;", "\u{2136}"), ("between;", "\u{226C}"), ("bfr;", "\u{1D51F}"), ("bigcap;", "\u{22C2}"), ("bigcirc;", "\u{25EF}"), ("bigcup;", "\u{22C3}"), ("bigodot;", "\u{2A00}"), ("bigoplus;", "\u{2A01}"), ("bigotimes;", "\u{2A02}"), ("bigsqcup;", "\u{2A06}"), ("bigstar;", "\u{2605}"), ("bigtriangledown;", "\u{25BD}"), ("bigtriangleup;", "\u{25B3}"), ("biguplus;", "\u{2A04}"), ("bigvee;", "\u{22C1}"), ("bigwedge;", "\u{22C0}"), ("bkarow;", "\u{290D}"), ("blacklozenge;", "\u{29EB}"), ("blacksquare;", "\u{25AA}"), ("blacktriangle;", "\u{25B4}"), ("blacktriangledown;", "\u{25BE}"), ("blacktriangleleft;", "\u{25C2}"), ("blacktriangleright;", "\u{25B8}"), ("blank;", "\u{2423}"), ("blk12;", "\u{2592}"), ("blk14;", "\u{2591}"), ("blk34;", "\u{2593}"), ("block;", "\u{2588}"), ("bne;", "\u{3D}\u{20E5}"), ("bnequiv;", "\u{2261}\u{20E5}"), ("bnot;", "\u{2310}"), ("bopf;", "\u{1D553}"), ("bot;", "\u{22A5}"), ("bottom;", "\u{22A5}"), ("bowtie;", "\u{22C8}"), ("boxDL;", "\u{2557}"), ("boxDR;", "\u{2554}"), ("boxDl;", "\u{2556}"), ("boxDr;", "\u{2553}"), ("boxH;", "\u{2550}"), ("boxHD;", "\u{2566}"), ("boxHU;", "\u{2569}"), ("boxHd;", "\u{2564}"), ("boxHu;", "\u{2567}"), ("boxUL;", "\u{255D}"), ("boxUR;", "\u{255A}"), ("boxUl;", "\u{255C}"), ("boxUr;", "\u{2559}"), ("boxV;", "\u{2551}"), ("boxVH;", "\u{256C}"), ("boxVL;", "\u{2563}"), ("boxVR;", "\u{2560}"), ("boxVh;", "\u{256B}"), ("boxVl;", "\u{2562}"), ("boxVr;", "\u{255F}"), ("boxbox;", "\u{29C9}"), ("boxdL;", "\u{2555}"), ("boxdR;", "\u{2552}"), ("boxdl;", "\u{2510}"), ("boxdr;", "\u{250C}"), ("boxh;", "\u{2500}"), ("boxhD;", "\u{2565}"), ("boxhU;", "\u{2568}"), ("boxhd;", "\u{252C}"), ("boxhu;", "\u{2534}"), ("boxminus;", "\u{229F}"), ("boxplus;", "\u{229E}"), ("boxtimes;", "\u{22A0}"), ("boxuL;", "\u{255B}"), ("boxuR;", "\u{2558}"), ("boxul;", "\u{2518}"), ("boxur;", "\u{2514}"), ("boxv;", "\u{2502}"), ("boxvH;", "\u{256A}"), ("boxvL;", "\u{2561}"), ("boxvR;", "\u{255E}"), ("boxvh;", "\u{253C}"), ("boxvl;", "\u{2524}"), ("boxvr;", "\u{251C}"), ("bprime;", "\u{2035}"), ("breve;", "\u{2D8}"), ("brvbar", "\u{A6}"), ("brvbar;", "\u{A6}"), ("bscr;", "\u{1D4B7}"), ("bsemi;", "\u{204F}"), ("bsim;", "\u{223D}"), ("bsime;", "\u{22CD}"), ("bsol;", "\u{5C}"), ("bsolb;", "\u{29C5}"), ("bsolhsub;", "\u{27C8}"), ("bull;", "\u{2022}"), ("bullet;", "\u{2022}"), ("bump;", "\u{224E}"), ("bumpE;", "\u{2AAE}"), ("bumpe;", "\u{224F}"), ("bumpeq;", "\u{224F}"), ("cacute;", "\u{107}"), ("cap;", "\u{2229}"), ("capand;", "\u{2A44}"), ("capbrcup;", "\u{2A49}"), ("capcap;", "\u{2A4B}"), ("capcup;", "\u{2A47}"), ("capdot;", "\u{2A40}"), ("caps;", "\u{2229}\u{FE00}"), ("caret;", "\u{2041}"), ("caron;", "\u{2C7}"), ("ccaps;", "\u{2A4D}"), ("ccaron;", "\u{10D}"), ("ccedil", "\u{E7}"), ("ccedil;", "\u{E7}"), ("ccirc;", "\u{109}"), ("ccups;", "\u{2A4C}"), ("ccupssm;", "\u{2A50}"), ("cdot;", "\u{10B}"), ("cedil", "\u{B8}"), ("cedil;", "\u{B8}"), ("cemptyv;", "\u{29B2}"), ("cent", "\u{A2}"), ("cent;", "\u{A2}"), ("centerdot;", "\u{B7}"), ("cfr;", "\u{1D520}"), ("chcy;", "\u{447}"), ("check;", "\u{2713}"), ("checkmark;", "\u{2713}"), ("chi;", "\u{3C7}"), ("cir;", "\u{25CB}"), ("cirE;", "\u{29C3}"), ("circ;", "\u{2C6}"), ("circeq;", "\u{2257}"), ("circlearrowleft;", "\u{21BA}"), ("circlearrowright;", "\u{21BB}"), ("circledR;", "\u{AE}"), ("circledS;", "\u{24C8}"), ("circledast;", "\u{229B}"), ("circledcirc;", "\u{229A}"), ("circleddash;", "\u{229D}"), ("cire;", "\u{2257}"), ("cirfnint;", "\u{2A10}"), ("cirmid;", "\u{2AEF}"), ("cirscir;", "\u{29C2}"), ("clubs;", "\u{2663}"), ("clubsuit;", "\u{2663}"), ("colon;", "\u{3A}"), ("colone;", "\u{2254}"), ("coloneq;", "\u{2254}"), ("comma;", "\u{2C}"), ("commat;", "\u{40}"), ("comp;", "\u{2201}"), ("compfn;", "\u{2218}"), ("complement;", "\u{2201}"), ("complexes;", "\u{2102}"), ("cong;", "\u{2245}"), ("congdot;", "\u{2A6D}"), ("conint;", "\u{222E}"), ("copf;", "\u{1D554}"), ("coprod;", "\u{2210}"), ("copy", "\u{A9}"), ("copy;", "\u{A9}"), ("copysr;", "\u{2117}"), ("crarr;", "\u{21B5}"), ("cross;", "\u{2717}"), ("cscr;", "\u{1D4B8}"), ("csub;", "\u{2ACF}"), ("csube;", "\u{2AD1}"), ("csup;", "\u{2AD0}"), ("csupe;", "\u{2AD2}"), ("ctdot;", "\u{22EF}"), ("cudarrl;", "\u{2938}"), ("cudarrr;", "\u{2935}"), ("cuepr;", "\u{22DE}"), ("cuesc;", "\u{22DF}"), ("cularr;", "\u{21B6}"), ("cularrp;", "\u{293D}"), ("cup;", "\u{222A}"), ("cupbrcap;", "\u{2A48}"), ("cupcap;", "\u{2A46}"), ("cupcup;", "\u{2A4A}"), ("cupdot;", "\u{228D}"), ("cupor;", "\u{2A45}"), ("cups;", "\u{222A}\u{FE00}"), ("curarr;", "\u{21B7}"), ("curarrm;", "\u{293C}"), ("curlyeqprec;", "\u{22DE}"), ("curlyeqsucc;", "\u{22DF}"), ("curlyvee;", "\u{22CE}"), ("curlywedge;", "\u{22CF}"), ("curren", "\u{A4}"), ("curren;", "\u{A4}"), ("curvearrowleft;", "\u{21B6}"), ("curvearrowright;", "\u{21B7}"), ("cuvee;", "\u{22CE}"), ("cuwed;", "\u{22CF}"), ("cwconint;", "\u{2232}"), ("cwint;", "\u{2231}"), ("cylcty;", "\u{232D}"), ("dArr;", "\u{21D3}"), ("dHar;", "\u{2965}"), ("dagger;", "\u{2020}"), ("daleth;", "\u{2138}"), ("darr;", "\u{2193}"), ("dash;", "\u{2010}"), ("dashv;", "\u{22A3}"), ("dbkarow;", "\u{290F}"), ("dblac;", "\u{2DD}"), ("dcaron;", "\u{10F}"), ("dcy;", "\u{434}"), ("dd;", "\u{2146}"), ("ddagger;", "\u{2021}"), ("ddarr;", "\u{21CA}"), ("ddotseq;", "\u{2A77}"), ("deg", "\u{B0}"), ("deg;", "\u{B0}"), ("delta;", "\u{3B4}"), ("demptyv;", "\u{29B1}"), ("dfisht;", "\u{297F}"), ("dfr;", "\u{1D521}"), ("dharl;", "\u{21C3}"), ("dharr;", "\u{21C2}"), ("diam;", "\u{22C4}"), ("diamond;", "\u{22C4}"), ("diamondsuit;", "\u{2666}"), ("diams;", "\u{2666}"), ("die;", "\u{A8}"), ("digamma;", "\u{3DD}"), ("disin;", "\u{22F2}"), ("div;", "\u{F7}"), ("divide", "\u{F7}"), ("divide;", "\u{F7}"), ("divideontimes;", "\u{22C7}"), ("divonx;", "\u{22C7}"), ("djcy;", "\u{452}"), ("dlcorn;", "\u{231E}"), ("dlcrop;", "\u{230D}"), ("dollar;", "\u{24}"), ("dopf;", "\u{1D555}"), ("dot;", "\u{2D9}"), ("doteq;", "\u{2250}"), ("doteqdot;", "\u{2251}"), ("dotminus;", "\u{2238}"), ("dotplus;", "\u{2214}"), ("dotsquare;", "\u{22A1}"), ("doublebarwedge;", "\u{2306}"), ("downarrow;", "\u{2193}"), ("downdownarrows;", "\u{21CA}"), ("downharpoonleft;", "\u{21C3}"), ("downharpoonright;", "\u{21C2}"), ("drbkarow;", "\u{2910}"), ("drcorn;", "\u{231F}"), ("drcrop;", "\u{230C}"), ("dscr;", "\u{1D4B9}"), ("dscy;", "\u{455}"), ("dsol;", "\u{29F6}"), ("dstrok;", "\u{111}"), ("dtdot;", "\u{22F1}"), ("dtri;", "\u{25BF}"), ("dtrif;", "\u{25BE}"), ("duarr;", "\u{21F5}"), ("duhar;", "\u{296F}"), ("dwangle;", "\u{29A6}"), ("dzcy;", "\u{45F}"), ("dzigrarr;", "\u{27FF}"), ("eDDot;", "\u{2A77}"), ("eDot;", "\u{2251}"), ("eacute", "\u{E9}"), ("eacute;", "\u{E9}"), ("easter;", "\u{2A6E}"), ("ecaron;", "\u{11B}"), ("ecir;", "\u{2256}"), ("ecirc", "\u{EA}"), ("ecirc;", "\u{EA}"), ("ecolon;", "\u{2255}"), ("ecy;", "\u{44D}"), ("edot;", "\u{117}"), ("ee;", "\u{2147}"), ("efDot;", "\u{2252}"), ("efr;", "\u{1D522}"), ("eg;", "\u{2A9A}"), ("egrave", "\u{E8}"), ("egrave;", "\u{E8}"), ("egs;", "\u{2A96}"), ("egsdot;", "\u{2A98}"), ("el;", "\u{2A99}"), ("elinters;", "\u{23E7}"), ("ell;", "\u{2113}"), ("els;", "\u{2A95}"), ("elsdot;", "\u{2A97}"), ("emacr;", "\u{113}"), ("empty;", "\u{2205}"), ("emptyset;", "\u{2205}"), ("emptyv;", "\u{2205}"), ("emsp13;", "\u{2004}"), ("emsp14;", "\u{2005}"), ("emsp;", "\u{2003}"), ("eng;", "\u{14B}"), ("ensp;", "\u{2002}"), ("eogon;", "\u{119}"), ("eopf;", "\u{1D556}"), ("epar;", "\u{22D5}"), ("eparsl;", "\u{29E3}"), ("eplus;", "\u{2A71}"), ("epsi;", "\u{3B5}"), ("epsilon;", "\u{3B5}"), ("epsiv;", "\u{3F5}"), ("eqcirc;", "\u{2256}"), ("eqcolon;", "\u{2255}"), ("eqsim;", "\u{2242}"), ("eqslantgtr;", "\u{2A96}"), ("eqslantless;", "\u{2A95}"), ("equals;", "\u{3D}"), ("equest;", "\u{225F}"), ("equiv;", "\u{2261}"), ("equivDD;", "\u{2A78}"), ("eqvparsl;", "\u{29E5}"), ("erDot;", "\u{2253}"), ("erarr;", "\u{2971}"), ("escr;", "\u{212F}"), ("esdot;", "\u{2250}"), ("esim;", "\u{2242}"), ("eta;", "\u{3B7}"), ("eth", "\u{F0}"), ("eth;", "\u{F0}"), ("euml", "\u{EB}"), ("euml;", "\u{EB}"), ("euro;", "\u{20AC}"), ("excl;", "\u{21}"), ("exist;", "\u{2203}"), ("expectation;", "\u{2130}"), ("exponentiale;", "\u{2147}"), ("fallingdotseq;", "\u{2252}"), ("fcy;", "\u{444}"), ("female;", "\u{2640}"), ("ffilig;", "\u{FB03}"), ("fflig;", "\u{FB00}"), ("ffllig;", "\u{FB04}"), ("ffr;", "\u{1D523}"), ("filig;", "\u{FB01}"), ("fjlig;", "\u{66}\u{6A}"), ("flat;", "\u{266D}"), ("fllig;", "\u{FB02}"), ("fltns;", "\u{25B1}"), ("fnof;", "\u{192}"), ("fopf;", "\u{1D557}"), ("forall;", "\u{2200}"), ("fork;", "\u{22D4}"), ("forkv;", "\u{2AD9}"), ("fpartint;", "\u{2A0D}"), ("frac12", "\u{BD}"), ("frac12;", "\u{BD}"), ("frac13;", "\u{2153}"), ("frac14", "\u{BC}"), ("frac14;", "\u{BC}"), ("frac15;", "\u{2155}"), ("frac16;", "\u{2159}"), ("frac18;", "\u{215B}"), ("frac23;", "\u{2154}"), ("frac25;", "\u{2156}"), ("frac34", "\u{BE}"), ("frac34;", "\u{BE}"), ("frac35;", "\u{2157}"), ("frac38;", "\u{215C}"), ("frac45;", "\u{2158}"), ("frac56;", "\u{215A}"), ("frac58;", "\u{215D}"), ("frac78;", "\u{215E}"), ("frasl;", "\u{2044}"), ("frown;", "\u{2322}"), ("fscr;", "\u{1D4BB}"), ("gE;", "\u{2267}"), ("gEl;", "\u{2A8C}"), ("gacute;", "\u{1F5}"), ("gamma;", "\u{3B3}"), ("gammad;", "\u{3DD}"), ("gap;", "\u{2A86}"), ("gbreve;", "\u{11F}"), ("gcirc;", "\u{11D}"), ("gcy;", "\u{433}"), ("gdot;", "\u{121}"), ("ge;", "\u{2265}"), ("gel;", "\u{22DB}"), ("geq;", "\u{2265}"), ("geqq;", "\u{2267}"), ("geqslant;", "\u{2A7E}"), ("ges;", "\u{2A7E}"), ("gescc;", "\u{2AA9}"), ("gesdot;", "\u{2A80}"), ("gesdoto;", "\u{2A82}"), ("gesdotol;", "\u{2A84}"), ("gesl;", "\u{22DB}\u{FE00}"), ("gesles;", "\u{2A94}"), ("gfr;", "\u{1D524}"), ("gg;", "\u{226B}"), ("ggg;", "\u{22D9}"), ("gimel;", "\u{2137}"), ("gjcy;", "\u{453}"), ("gl;", "\u{2277}"), ("glE;", "\u{2A92}"), ("gla;", "\u{2AA5}"), ("glj;", "\u{2AA4}"), ("gnE;", "\u{2269}"), ("gnap;", "\u{2A8A}"), ("gnapprox;", "\u{2A8A}"), ("gne;", "\u{2A88}"), ("gneq;", "\u{2A88}"), ("gneqq;", "\u{2269}"), ("gnsim;", "\u{22E7}"), ("gopf;", "\u{1D558}"), ("grave;", "\u{60}"), ("gscr;", "\u{210A}"), ("gsim;", "\u{2273}"), ("gsime;", "\u{2A8E}"), ("gsiml;", "\u{2A90}"), ("gt", "\u{3E}"), ("gt;", "\u{3E}"), ("gtcc;", "\u{2AA7}"), ("gtcir;", "\u{2A7A}"), ("gtdot;", "\u{22D7}"), ("gtlPar;", "\u{2995}"), ("gtquest;", "\u{2A7C}"), ("gtrapprox;", "\u{2A86}"), ("gtrarr;", "\u{2978}"), ("gtrdot;", "\u{22D7}"), ("gtreqless;", "\u{22DB}"), ("gtreqqless;", "\u{2A8C}"), ("gtrless;", "\u{2277}"), ("gtrsim;", "\u{2273}"), ("gvertneqq;", "\u{2269}\u{FE00}"), ("gvnE;", "\u{2269}\u{FE00}"), ("hArr;", "\u{21D4}"), ("hairsp;", "\u{200A}"), ("half;", "\u{BD}"), ("hamilt;", "\u{210B}"), ("hardcy;", "\u{44A}"), ("harr;", "\u{2194}"), ("harrcir;", "\u{2948}"), ("harrw;", "\u{21AD}"), ("hbar;", "\u{210F}"), ("hcirc;", "\u{125}"), ("hearts;", "\u{2665}"), ("heartsuit;", "\u{2665}"), ("hellip;", "\u{2026}"), ("hercon;", "\u{22B9}"), ("hfr;", "\u{1D525}"), ("hksearow;", "\u{2925}"), ("hkswarow;", "\u{2926}"), ("hoarr;", "\u{21FF}"), ("homtht;", "\u{223B}"), ("hookleftarrow;", "\u{21A9}"), ("hookrightarrow;", "\u{21AA}"), ("hopf;", "\u{1D559}"), ("horbar;", "\u{2015}"), ("hscr;", "\u{1D4BD}"), ("hslash;", "\u{210F}"), ("hstrok;", "\u{127}"), ("hybull;", "\u{2043}"), ("hyphen;", "\u{2010}"), ("iacute", "\u{ED}"), ("iacute;", "\u{ED}"), ("ic;", "\u{2063}"), ("icirc", "\u{EE}"), ("icirc;", "\u{EE}"), ("icy;", "\u{438}"), ("iecy;", "\u{435}"), ("iexcl", "\u{A1}"), ("iexcl;", "\u{A1}"), ("iff;", "\u{21D4}"), ("ifr;", "\u{1D526}"), ("igrave", "\u{EC}"), ("igrave;", "\u{EC}"), ("ii;", "\u{2148}"), ("iiiint;", "\u{2A0C}"), ("iiint;", "\u{222D}"), ("iinfin;", "\u{29DC}"), ("iiota;", "\u{2129}"), ("ijlig;", "\u{133}"), ("imacr;", "\u{12B}"), ("image;", "\u{2111}"), ("imagline;", "\u{2110}"), ("imagpart;", "\u{2111}"), ("imath;", "\u{131}"), ("imof;", "\u{22B7}"), ("imped;", "\u{1B5}"), ("in;", "\u{2208}"), ("incare;", "\u{2105}"), ("infin;", "\u{221E}"), ("infintie;", "\u{29DD}"), ("inodot;", "\u{131}"), ("int;", "\u{222B}"), ("intcal;", "\u{22BA}"), ("integers;", "\u{2124}"), ("intercal;", "\u{22BA}"), ("intlarhk;", "\u{2A17}"), ("intprod;", "\u{2A3C}"), ("iocy;", "\u{451}"), ("iogon;", "\u{12F}"), ("iopf;", "\u{1D55A}"), ("iota;", "\u{3B9}"), ("iprod;", "\u{2A3C}"), ("iquest", "\u{BF}"), ("iquest;", "\u{BF}"), ("iscr;", "\u{1D4BE}"), ("isin;", "\u{2208}"), ("isinE;", "\u{22F9}"), ("isindot;", "\u{22F5}"), ("isins;", "\u{22F4}"), ("isinsv;", "\u{22F3}"), ("isinv;", "\u{2208}"), ("it;", "\u{2062}"), ("itilde;", "\u{129}"), ("iukcy;", "\u{456}"), ("iuml", "\u{EF}"), ("iuml;", "\u{EF}"), ("jcirc;", "\u{135}"), ("jcy;", "\u{439}"), ("jfr;", "\u{1D527}"), ("jmath;", "\u{237}"), ("jopf;", "\u{1D55B}"), ("jscr;", "\u{1D4BF}"), ("jsercy;", "\u{458}"), ("jukcy;", "\u{454}"), ("kappa;", "\u{3BA}"), ("kappav;", "\u{3F0}"), ("kcedil;", "\u{137}"), ("kcy;", "\u{43A}"), ("kfr;", "\u{1D528}"), ("kgreen;", "\u{138}"), ("khcy;", "\u{445}"), ("kjcy;", "\u{45C}"), ("kopf;", "\u{1D55C}"), ("kscr;", "\u{1D4C0}"), ("lAarr;", "\u{21DA}"), ("lArr;", "\u{21D0}"), ("lAtail;", "\u{291B}"), ("lBarr;", "\u{290E}"), ("lE;", "\u{2266}"), ("lEg;", "\u{2A8B}"), ("lHar;", "\u{2962}"), ("lacute;", "\u{13A}"), ("laemptyv;", "\u{29B4}"), ("lagran;", "\u{2112}"), ("lambda;", "\u{3BB}"), ("lang;", "\u{27E8}"), ("langd;", "\u{2991}"), ("langle;", "\u{27E8}"), ("lap;", "\u{2A85}"), ("laquo", "\u{AB}"), ("laquo;", "\u{AB}"), ("larr;", "\u{2190}"), ("larrb;", "\u{21E4}"), ("larrbfs;", "\u{291F}"), ("larrfs;", "\u{291D}"), ("larrhk;", "\u{21A9}"), ("larrlp;", "\u{21AB}"), ("larrpl;", "\u{2939}"), ("larrsim;", "\u{2973}"), ("larrtl;", "\u{21A2}"), ("lat;", "\u{2AAB}"), ("latail;", "\u{2919}"), ("late;", "\u{2AAD}"), ("lates;", "\u{2AAD}\u{FE00}"), ("lbarr;", "\u{290C}"), ("lbbrk;", "\u{2772}"), ("lbrace;", "\u{7B}"), ("lbrack;", "\u{5B}"), ("lbrke;", "\u{298B}"), ("lbrksld;", "\u{298F}"), ("lbrkslu;", "\u{298D}"), ("lcaron;", "\u{13E}"), ("lcedil;", "\u{13C}"), ("lceil;", "\u{2308}"), ("lcub;", "\u{7B}"), ("lcy;", "\u{43B}"), ("ldca;", "\u{2936}"), ("ldquo;", "\u{201C}"), ("ldquor;", "\u{201E}"), ("ldrdhar;", "\u{2967}"), ("ldrushar;", "\u{294B}"), ("ldsh;", "\u{21B2}"), ("le;", "\u{2264}"), ("leftarrow;", "\u{2190}"), ("leftarrowtail;", "\u{21A2}"), ("leftharpoondown;", "\u{21BD}"), ("leftharpoonup;", "\u{21BC}"), ("leftleftarrows;", "\u{21C7}"), ("leftrightarrow;", "\u{2194}"), ("leftrightarrows;", "\u{21C6}"), ("leftrightharpoons;", "\u{21CB}"), ("leftrightsquigarrow;", "\u{21AD}"), ("leftthreetimes;", "\u{22CB}"), ("leg;", "\u{22DA}"), ("leq;", "\u{2264}"), ("leqq;", "\u{2266}"), ("leqslant;", "\u{2A7D}"), ("les;", "\u{2A7D}"), ("lescc;", "\u{2AA8}"), ("lesdot;", "\u{2A7F}"), ("lesdoto;", "\u{2A81}"), ("lesdotor;", "\u{2A83}"), ("lesg;", "\u{22DA}\u{FE00}"), ("lesges;", "\u{2A93}"), ("lessapprox;", "\u{2A85}"), ("lessdot;", "\u{22D6}"), ("lesseqgtr;", "\u{22DA}"), ("lesseqqgtr;", "\u{2A8B}"), ("lessgtr;", "\u{2276}"), ("lesssim;", "\u{2272}"), ("lfisht;", "\u{297C}"), ("lfloor;", "\u{230A}"), ("lfr;", "\u{1D529}"), ("lg;", "\u{2276}"), ("lgE;", "\u{2A91}"), ("lhard;", "\u{21BD}"), ("lharu;", "\u{21BC}"), ("lharul;", "\u{296A}"), ("lhblk;", "\u{2584}"), ("ljcy;", "\u{459}"), ("ll;", "\u{226A}"), ("llarr;", "\u{21C7}"), ("llcorner;", "\u{231E}"), ("llhard;", "\u{296B}"), ("lltri;", "\u{25FA}"), ("lmidot;", "\u{140}"), ("lmoust;", "\u{23B0}"), ("lmoustache;", "\u{23B0}"), ("lnE;", "\u{2268}"), ("lnap;", "\u{2A89}"), ("lnapprox;", "\u{2A89}"), ("lne;", "\u{2A87}"), ("lneq;", "\u{2A87}"), ("lneqq;", "\u{2268}"), ("lnsim;", "\u{22E6}"), ("loang;", "\u{27EC}"), ("loarr;", "\u{21FD}"), ("lobrk;", "\u{27E6}"), ("longleftarrow;", "\u{27F5}"), ("longleftrightarrow;", "\u{27F7}"), ("longmapsto;", "\u{27FC}"), ("longrightarrow;", "\u{27F6}"), ("looparrowleft;", "\u{21AB}"), ("looparrowright;", "\u{21AC}"), ("lopar;", "\u{2985}"), ("lopf;", "\u{1D55D}"), ("loplus;", "\u{2A2D}"), ("lotimes;", "\u{2A34}"), ("lowast;", "\u{2217}"), ("lowbar;", "\u{5F}"), ("loz;", "\u{25CA}"), ("lozenge;", "\u{25CA}"), ("lozf;", "\u{29EB}"), ("lpar;", "\u{28}"), ("lparlt;", "\u{2993}"), ("lrarr;", "\u{21C6}"), ("lrcorner;", "\u{231F}"), ("lrhar;", "\u{21CB}"), ("lrhard;", "\u{296D}"), ("lrm;", "\u{200E}"), ("lrtri;", "\u{22BF}"), ("lsaquo;", "\u{2039}"), ("lscr;", "\u{1D4C1}"), ("lsh;", "\u{21B0}"), ("lsim;", "\u{2272}"), ("lsime;", "\u{2A8D}"), ("lsimg;", "\u{2A8F}"), ("lsqb;", "\u{5B}"), ("lsquo;", "\u{2018}"), ("lsquor;", "\u{201A}"), ("lstrok;", "\u{142}"), ("lt", "\u{3C}"), ("lt;", "\u{3C}"), ("ltcc;", "\u{2AA6}"), ("ltcir;", "\u{2A79}"), ("ltdot;", "\u{22D6}"), ("lthree;", "\u{22CB}"), ("ltimes;", "\u{22C9}"), ("ltlarr;", "\u{2976}"), ("ltquest;", "\u{2A7B}"), ("ltrPar;", "\u{2996}"), ("ltri;", "\u{25C3}"), ("ltrie;", "\u{22B4}"), ("ltrif;", "\u{25C2}"), ("lurdshar;", "\u{294A}"), ("luruhar;", "\u{2966}"), ("lvertneqq;", "\u{2268}\u{FE00}"), ("lvnE;", "\u{2268}\u{FE00}"), ("mDDot;", "\u{223A}"), ("macr", "\u{AF}"), ("macr;", "\u{AF}"), ("male;", "\u{2642}"), ("malt;", "\u{2720}"), ("maltese;", "\u{2720}"), ("map;", "\u{21A6}"), ("mapsto;", "\u{21A6}"), ("mapstodown;", "\u{21A7}"), ("mapstoleft;", "\u{21A4}"), ("mapstoup;", "\u{21A5}"), ("marker;", "\u{25AE}"), ("mcomma;", "\u{2A29}"), ("mcy;", "\u{43C}"), ("mdash;", "\u{2014}"), ("measuredangle;", "\u{2221}"), ("mfr;", "\u{1D52A}"), ("mho;", "\u{2127}"), ("micro", "\u{B5}"), ("micro;", "\u{B5}"), ("mid;", "\u{2223}"), ("midast;", "\u{2A}"), ("midcir;", "\u{2AF0}"), ("middot", "\u{B7}"), ("middot;", "\u{B7}"), ("minus;", "\u{2212}"), ("minusb;", "\u{229F}"), ("minusd;", "\u{2238}"), ("minusdu;", "\u{2A2A}"), ("mlcp;", "\u{2ADB}"), ("mldr;", "\u{2026}"), ("mnplus;", "\u{2213}"), ("models;", "\u{22A7}"), ("mopf;", "\u{1D55E}"), ("mp;", "\u{2213}"), ("mscr;", "\u{1D4C2}"), ("mstpos;", "\u{223E}"), ("mu;", "\u{3BC}"), ("multimap;", "\u{22B8}"), ("mumap;", "\u{22B8}"), ("nGg;", "\u{22D9}\u{338}"), ("nGt;", "\u{226B}\u{20D2}"), ("nGtv;", "\u{226B}\u{338}"), ("nLeftarrow;", "\u{21CD}"), ("nLeftrightarrow;", "\u{21CE}"), ("nLl;", "\u{22D8}\u{338}"), ("nLt;", "\u{226A}\u{20D2}"), ("nLtv;", "\u{226A}\u{338}"), ("nRightarrow;", "\u{21CF}"), ("nVDash;", "\u{22AF}"), ("nVdash;", "\u{22AE}"), ("nabla;", "\u{2207}"), ("nacute;", "\u{144}"), ("nang;", "\u{2220}\u{20D2}"), ("nap;", "\u{2249}"), ("napE;", "\u{2A70}\u{338}"), ("napid;", "\u{224B}\u{338}"), ("napos;", "\u{149}"), ("napprox;", "\u{2249}"), ("natur;", "\u{266E}"), ("natural;", "\u{266E}"), ("naturals;", "\u{2115}"), ("nbsp", "\u{A0}"), ("nbsp;", "\u{A0}"), ("nbump;", "\u{224E}\u{338}"), ("nbumpe;", "\u{224F}\u{338}"), ("ncap;", "\u{2A43}"), ("ncaron;", "\u{148}"), ("ncedil;", "\u{146}"), ("ncong;", "\u{2247}"), ("ncongdot;", "\u{2A6D}\u{338}"), ("ncup;", "\u{2A42}"), ("ncy;", "\u{43D}"), ("ndash;", "\u{2013}"), ("ne;", "\u{2260}"), ("neArr;", "\u{21D7}"), ("nearhk;", "\u{2924}"), ("nearr;", "\u{2197}"), ("nearrow;", "\u{2197}"), ("nedot;", "\u{2250}\u{338}"), ("nequiv;", "\u{2262}"), ("nesear;", "\u{2928}"), ("nesim;", "\u{2242}\u{338}"), ("nexist;", "\u{2204}"), ("nexists;", "\u{2204}"), ("nfr;", "\u{1D52B}"), ("ngE;", "\u{2267}\u{338}"), ("nge;", "\u{2271}"), ("ngeq;", "\u{2271}"), ("ngeqq;", "\u{2267}\u{338}"), ("ngeqslant;", "\u{2A7E}\u{338}"), ("nges;", "\u{2A7E}\u{338}"), ("ngsim;", "\u{2275}"), ("ngt;", "\u{226F}"), ("ngtr;", "\u{226F}"), ("nhArr;", "\u{21CE}"), ("nharr;", "\u{21AE}"), ("nhpar;", "\u{2AF2}"), ("ni;", "\u{220B}"), ("nis;", "\u{22FC}"), ("nisd;", "\u{22FA}"), ("niv;", "\u{220B}"), ("njcy;", "\u{45A}"), ("nlArr;", "\u{21CD}"), ("nlE;", "\u{2266}\u{338}"), ("nlarr;", "\u{219A}"), ("nldr;", "\u{2025}"), ("nle;", "\u{2270}"), ("nleftarrow;", "\u{219A}"), ("nleftrightarrow;", "\u{21AE}"), ("nleq;", "\u{2270}"), ("nleqq;", "\u{2266}\u{338}"), ("nleqslant;", "\u{2A7D}\u{338}"), ("nles;", "\u{2A7D}\u{338}"), ("nless;", "\u{226E}"), ("nlsim;", "\u{2274}"), ("nlt;", "\u{226E}"), ("nltri;", "\u{22EA}"), ("nltrie;", "\u{22EC}"), ("nmid;", "\u{2224}"), ("nopf;", "\u{1D55F}"), ("not", "\u{AC}"), ("not;", "\u{AC}"), ("notin;", "\u{2209}"), ("notinE;", "\u{22F9}\u{338}"), ("notindot;", "\u{22F5}\u{338}"), ("notinva;", "\u{2209}"), ("notinvb;", "\u{22F7}"), ("notinvc;", "\u{22F6}"), ("notni;", "\u{220C}"), ("notniva;", "\u{220C}"), ("notnivb;", "\u{22FE}"), ("notnivc;", "\u{22FD}"), ("npar;", "\u{2226}"), ("nparallel;", "\u{2226}"), ("nparsl;", "\u{2AFD}\u{20E5}"), ("npart;", "\u{2202}\u{338}"), ("npolint;", "\u{2A14}"), ("npr;", "\u{2280}"), ("nprcue;", "\u{22E0}"), ("npre;", "\u{2AAF}\u{338}"), ("nprec;", "\u{2280}"), ("npreceq;", "\u{2AAF}\u{338}"), ("nrArr;", "\u{21CF}"), ("nrarr;", "\u{219B}"), ("nrarrc;", "\u{2933}\u{338}"), ("nrarrw;", "\u{219D}\u{338}"), ("nrightarrow;", "\u{219B}"), ("nrtri;", "\u{22EB}"), ("nrtrie;", "\u{22ED}"), ("nsc;", "\u{2281}"), ("nsccue;", "\u{22E1}"), ("nsce;", "\u{2AB0}\u{338}"), ("nscr;", "\u{1D4C3}"), ("nshortmid;", "\u{2224}"), ("nshortparallel;", "\u{2226}"), ("nsim;", "\u{2241}"), ("nsime;", "\u{2244}"), ("nsimeq;", "\u{2244}"), ("nsmid;", "\u{2224}"), ("nspar;", "\u{2226}"), ("nsqsube;", "\u{22E2}"), ("nsqsupe;", "\u{22E3}"), ("nsub;", "\u{2284}"), ("nsubE;", "\u{2AC5}\u{338}"), ("nsube;", "\u{2288}"), ("nsubset;", "\u{2282}\u{20D2}"), ("nsubseteq;", "\u{2288}"), ("nsubseteqq;", "\u{2AC5}\u{338}"), ("nsucc;", "\u{2281}"), ("nsucceq;", "\u{2AB0}\u{338}"), ("nsup;", "\u{2285}"), ("nsupE;", "\u{2AC6}\u{338}"), ("nsupe;", "\u{2289}"), ("nsupset;", "\u{2283}\u{20D2}"), ("nsupseteq;", "\u{2289}"), ("nsupseteqq;", "\u{2AC6}\u{338}"), ("ntgl;", "\u{2279}"), ("ntilde", "\u{F1}"), ("ntilde;", "\u{F1}"), ("ntlg;", "\u{2278}"), ("ntriangleleft;", "\u{22EA}"), ("ntrianglelefteq;", "\u{22EC}"), ("ntriangleright;", "\u{22EB}"), ("ntrianglerighteq;", "\u{22ED}"), ("nu;", "\u{3BD}"), ("num;", "\u{23}"), ("numero;", "\u{2116}"), ("numsp;", "\u{2007}"), ("nvDash;", "\u{22AD}"), ("nvHarr;", "\u{2904}"), ("nvap;", "\u{224D}\u{20D2}"), ("nvdash;", "\u{22AC}"), ("nvge;", "\u{2265}\u{20D2}"), ("nvgt;", "\u{3E}\u{20D2}"), ("nvinfin;", "\u{29DE}"), ("nvlArr;", "\u{2902}"), ("nvle;", "\u{2264}\u{20D2}"), ("nvlt;", "\u{3C}\u{20D2}"), ("nvltrie;", "\u{22B4}\u{20D2}"), ("nvrArr;", "\u{2903}"), ("nvrtrie;", "\u{22B5}\u{20D2}"), ("nvsim;", "\u{223C}\u{20D2}"), ("nwArr;", "\u{21D6}"), ("nwarhk;", "\u{2923}"), ("nwarr;", "\u{2196}"), ("nwarrow;", "\u{2196}"), ("nwnear;", "\u{2927}"), ("oS;", "\u{24C8}"), ("oacute", "\u{F3}"), ("oacute;", "\u{F3}"), ("oast;", "\u{229B}"), ("ocir;", "\u{229A}"), ("ocirc", "\u{F4}"), ("ocirc;", "\u{F4}"), ("ocy;", "\u{43E}"), ("odash;", "\u{229D}"), ("odblac;", "\u{151}"), ("odiv;", "\u{2A38}"), ("odot;", "\u{2299}"), ("odsold;", "\u{29BC}"), ("oelig;", "\u{153}"), ("ofcir;", "\u{29BF}"), ("ofr;", "\u{1D52C}"), ("ogon;", "\u{2DB}"), ("ograve", "\u{F2}"), ("ograve;", "\u{F2}"), ("ogt;", "\u{29C1}"), ("ohbar;", "\u{29B5}"), ("ohm;", "\u{3A9}"), ("oint;", "\u{222E}"), ("olarr;", "\u{21BA}"), ("olcir;", "\u{29BE}"), ("olcross;", "\u{29BB}"), ("oline;", "\u{203E}"), ("olt;", "\u{29C0}"), ("omacr;", "\u{14D}"), ("omega;", "\u{3C9}"), ("omicron;", "\u{3BF}"), ("omid;", "\u{29B6}"), ("ominus;", "\u{2296}"), ("oopf;", "\u{1D560}"), ("opar;", "\u{29B7}"), ("operp;", "\u{29B9}"), ("oplus;", "\u{2295}"), ("or;", "\u{2228}"), ("orarr;", "\u{21BB}"), ("ord;", "\u{2A5D}"), ("order;", "\u{2134}"), ("orderof;", "\u{2134}"), ("ordf", "\u{AA}"), ("ordf;", "\u{AA}"), ("ordm", "\u{BA}"), ("ordm;", "\u{BA}"), ("origof;", "\u{22B6}"), ("oror;", "\u{2A56}"), ("orslope;", "\u{2A57}"), ("orv;", "\u{2A5B}"), ("oscr;", "\u{2134}"), ("oslash", "\u{F8}"), ("oslash;", "\u{F8}"), ("osol;", "\u{2298}"), ("otilde", "\u{F5}"), ("otilde;", "\u{F5}"), ("otimes;", "\u{2297}"), ("otimesas;", "\u{2A36}"), ("ouml", "\u{F6}"), ("ouml;", "\u{F6}"), ("ovbar;", "\u{233D}"), ("par;", "\u{2225}"), ("para", "\u{B6}"), ("para;", "\u{B6}"), ("parallel;", "\u{2225}"), ("parsim;", "\u{2AF3}"), ("parsl;", "\u{2AFD}"), ("part;", "\u{2202}"), ("pcy;", "\u{43F}"), ("percnt;", "\u{25}"), ("period;", "\u{2E}"), ("permil;", "\u{2030}"), ("perp;", "\u{22A5}"), ("pertenk;", "\u{2031}"), ("pfr;", "\u{1D52D}"), ("phi;", "\u{3C6}"), ("phiv;", "\u{3D5}"), ("phmmat;", "\u{2133}"), ("phone;", "\u{260E}"), ("pi;", "\u{3C0}"), ("pitchfork;", "\u{22D4}"), ("piv;", "\u{3D6}"), ("planck;", "\u{210F}"), ("planckh;", "\u{210E}"), ("plankv;", "\u{210F}"), ("plus;", "\u{2B}"), ("plusacir;", "\u{2A23}"), ("plusb;", "\u{229E}"), ("pluscir;", "\u{2A22}"), ("plusdo;", "\u{2214}"), ("plusdu;", "\u{2A25}"), ("pluse;", "\u{2A72}"), ("plusmn", "\u{B1}"), ("plusmn;", "\u{B1}"), ("plussim;", "\u{2A26}"), ("plustwo;", "\u{2A27}"), ("pm;", "\u{B1}"), ("pointint;", "\u{2A15}"), ("popf;", "\u{1D561}"), ("pound", "\u{A3}"), ("pound;", "\u{A3}"), ("pr;", "\u{227A}"), ("prE;", "\u{2AB3}"), ("prap;", "\u{2AB7}"), ("prcue;", "\u{227C}"), ("pre;", "\u{2AAF}"), ("prec;", "\u{227A}"), ("precapprox;", "\u{2AB7}"), ("preccurlyeq;", "\u{227C}"), ("preceq;", "\u{2AAF}"), ("precnapprox;", "\u{2AB9}"), ("precneqq;", "\u{2AB5}"), ("precnsim;", "\u{22E8}"), ("precsim;", "\u{227E}"), ("prime;", "\u{2032}"), ("primes;", "\u{2119}"), ("prnE;", "\u{2AB5}"), ("prnap;", "\u{2AB9}"), ("prnsim;", "\u{22E8}"), ("prod;", "\u{220F}"), ("profalar;", "\u{232E}"), ("profline;", "\u{2312}"), ("profsurf;", "\u{2313}"), ("prop;", "\u{221D}"), ("propto;", "\u{221D}"), ("prsim;", "\u{227E}"), ("prurel;", "\u{22B0}"), ("pscr;", "\u{1D4C5}"), ("psi;", "\u{3C8}"), ("puncsp;", "\u{2008}"), ("qfr;", "\u{1D52E}"), ("qint;", "\u{2A0C}"), ("qopf;", "\u{1D562}"), ("qprime;", "\u{2057}"), ("qscr;", "\u{1D4C6}"), ("quaternions;", "\u{210D}"), ("quatint;", "\u{2A16}"), ("quest;", "\u{3F}"), ("questeq;", "\u{225F}"), ("quot", "\u{22}"), ("quot;", "\u{22}"), ("rAarr;", "\u{21DB}"), ("rArr;", "\u{21D2}"), ("rAtail;", "\u{291C}"), ("rBarr;", "\u{290F}"), ("rHar;", "\u{2964}"), ("race;", "\u{223D}\u{331}"), ("racute;", "\u{155}"), ("radic;", "\u{221A}"), ("raemptyv;", "\u{29B3}"), ("rang;", "\u{27E9}"), ("rangd;", "\u{2992}"), ("range;", "\u{29A5}"), ("rangle;", "\u{27E9}"), ("raquo", "\u{BB}"), ("raquo;", "\u{BB}"), ("rarr;", "\u{2192}"), ("rarrap;", "\u{2975}"), ("rarrb;", "\u{21E5}"), ("rarrbfs;", "\u{2920}"), ("rarrc;", "\u{2933}"), ("rarrfs;", "\u{291E}"), ("rarrhk;", "\u{21AA}"), ("rarrlp;", "\u{21AC}"), ("rarrpl;", "\u{2945}"), ("rarrsim;", "\u{2974}"), ("rarrtl;", "\u{21A3}"), ("rarrw;", "\u{219D}"), ("ratail;", "\u{291A}"), ("ratio;", "\u{2236}"), ("rationals;", "\u{211A}"), ("rbarr;", "\u{290D}"), ("rbbrk;", "\u{2773}"), ("rbrace;", "\u{7D}"), ("rbrack;", "\u{5D}"), ("rbrke;", "\u{298C}"), ("rbrksld;", "\u{298E}"), ("rbrkslu;", "\u{2990}"), ("rcaron;", "\u{159}"), ("rcedil;", "\u{157}"), ("rceil;", "\u{2309}"), ("rcub;", "\u{7D}"), ("rcy;", "\u{440}"), ("rdca;", "\u{2937}"), ("rdldhar;", "\u{2969}"), ("rdquo;", "\u{201D}"), ("rdquor;", "\u{201D}"), ("rdsh;", "\u{21B3}"), ("real;", "\u{211C}"), ("realine;", "\u{211B}"), ("realpart;", "\u{211C}"), ("reals;", "\u{211D}"), ("rect;", "\u{25AD}"), ("reg", "\u{AE}"), ("reg;", "\u{AE}"), ("rfisht;", "\u{297D}"), ("rfloor;", "\u{230B}"), ("rfr;", "\u{1D52F}"), ("rhard;", "\u{21C1}"), ("rharu;", "\u{21C0}"), ("rharul;", "\u{296C}"), ("rho;", "\u{3C1}"), ("rhov;", "\u{3F1}"), ("rightarrow;", "\u{2192}"), ("rightarrowtail;", "\u{21A3}"), ("rightharpoondown;", "\u{21C1}"), ("rightharpoonup;", "\u{21C0}"), ("rightleftarrows;", "\u{21C4}"), ("rightleftharpoons;", "\u{21CC}"), ("rightrightarrows;", "\u{21C9}"), ("rightsquigarrow;", "\u{219D}"), ("rightthreetimes;", "\u{22CC}"), ("ring;", "\u{2DA}"), ("risingdotseq;", "\u{2253}"), ("rlarr;", "\u{21C4}"), ("rlhar;", "\u{21CC}"), ("rlm;", "\u{200F}"), ("rmoust;", "\u{23B1}"), ("rmoustache;", "\u{23B1}"), ("rnmid;", "\u{2AEE}"), ("roang;", "\u{27ED}"), ("roarr;", "\u{21FE}"), ("robrk;", "\u{27E7}"), ("ropar;", "\u{2986}"), ("ropf;", "\u{1D563}"), ("roplus;", "\u{2A2E}"), ("rotimes;", "\u{2A35}"), ("rpar;", "\u{29}"), ("rpargt;", "\u{2994}"), ("rppolint;", "\u{2A12}"), ("rrarr;", "\u{21C9}"), ("rsaquo;", "\u{203A}"), ("rscr;", "\u{1D4C7}"), ("rsh;", "\u{21B1}"), ("rsqb;", "\u{5D}"), ("rsquo;", "\u{2019}"), ("rsquor;", "\u{2019}"), ("rthree;", "\u{22CC}"), ("rtimes;", "\u{22CA}"), ("rtri;", "\u{25B9}"), ("rtrie;", "\u{22B5}"), ("rtrif;", "\u{25B8}"), ("rtriltri;", "\u{29CE}"), ("ruluhar;", "\u{2968}"), ("rx;", "\u{211E}"), ("sacute;", "\u{15B}"), ("sbquo;", "\u{201A}"), ("sc;", "\u{227B}"), ("scE;", "\u{2AB4}"), ("scap;", "\u{2AB8}"), ("scaron;", "\u{161}"), ("sccue;", "\u{227D}"), ("sce;", "\u{2AB0}"), ("scedil;", "\u{15F}"), ("scirc;", "\u{15D}"), ("scnE;", "\u{2AB6}"), ("scnap;", "\u{2ABA}"), ("scnsim;", "\u{22E9}"), ("scpolint;", "\u{2A13}"), ("scsim;", "\u{227F}"), ("scy;", "\u{441}"), ("sdot;", "\u{22C5}"), ("sdotb;", "\u{22A1}"), ("sdote;", "\u{2A66}"), ("seArr;", "\u{21D8}"), ("searhk;", "\u{2925}"), ("searr;", "\u{2198}"), ("searrow;", "\u{2198}"), ("sect", "\u{A7}"), ("sect;", "\u{A7}"), ("semi;", "\u{3B}"), ("seswar;", "\u{2929}"), ("setminus;", "\u{2216}"), ("setmn;", "\u{2216}"), ("sext;", "\u{2736}"), ("sfr;", "\u{1D530}"), ("sfrown;", "\u{2322}"), ("sharp;", "\u{266F}"), ("shchcy;", "\u{449}"), ("shcy;", "\u{448}"), ("shortmid;", "\u{2223}"), ("shortparallel;", "\u{2225}"), ("shy", "\u{AD}"), ("shy;", "\u{AD}"), ("sigma;", "\u{3C3}"), ("sigmaf;", "\u{3C2}"), ("sigmav;", "\u{3C2}"), ("sim;", "\u{223C}"), ("simdot;", "\u{2A6A}"), ("sime;", "\u{2243}"), ("simeq;", "\u{2243}"), ("simg;", "\u{2A9E}"), ("simgE;", "\u{2AA0}"), ("siml;", "\u{2A9D}"), ("simlE;", "\u{2A9F}"), ("simne;", "\u{2246}"), ("simplus;", "\u{2A24}"), ("simrarr;", "\u{2972}"), ("slarr;", "\u{2190}"), ("smallsetminus;", "\u{2216}"), ("smashp;", "\u{2A33}"), ("smeparsl;", "\u{29E4}"), ("smid;", "\u{2223}"), ("smile;", "\u{2323}"), ("smt;", "\u{2AAA}"), ("smte;", "\u{2AAC}"), ("smtes;", "\u{2AAC}\u{FE00}"), ("softcy;", "\u{44C}"), ("sol;", "\u{2F}"), ("solb;", "\u{29C4}"), ("solbar;", "\u{233F}"), ("sopf;", "\u{1D564}"), ("spades;", "\u{2660}"), ("spadesuit;", "\u{2660}"), ("spar;", "\u{2225}"), ("sqcap;", "\u{2293}"), ("sqcaps;", "\u{2293}\u{FE00}"), ("sqcup;", "\u{2294}"), ("sqcups;", "\u{2294}\u{FE00}"), ("sqsub;", "\u{228F}"), ("sqsube;", "\u{2291}"), ("sqsubset;", "\u{228F}"), ("sqsubseteq;", "\u{2291}"), ("sqsup;", "\u{2290}"), ("sqsupe;", "\u{2292}"), ("sqsupset;", "\u{2290}"), ("sqsupseteq;", "\u{2292}"), ("squ;", "\u{25A1}"), ("square;", "\u{25A1}"), ("squarf;", "\u{25AA}"), ("squf;", "\u{25AA}"), ("srarr;", "\u{2192}"), ("sscr;", "\u{1D4C8}"), ("ssetmn;", "\u{2216}"), ("ssmile;", "\u{2323}"), ("sstarf;", "\u{22C6}"), ("star;", "\u{2606}"), ("starf;", "\u{2605}"), ("straightepsilon;", "\u{3F5}"), ("straightphi;", "\u{3D5}"), ("strns;", "\u{AF}"), ("sub;", "\u{2282}"), ("subE;", "\u{2AC5}"), ("subdot;", "\u{2ABD}"), ("sube;", "\u{2286}"), ("subedot;", "\u{2AC3}"), ("submult;", "\u{2AC1}"), ("subnE;", "\u{2ACB}"), ("subne;", "\u{228A}"), ("subplus;", "\u{2ABF}"), ("subrarr;", "\u{2979}"), ("subset;", "\u{2282}"), ("subseteq;", "\u{2286}"), ("subseteqq;", "\u{2AC5}"), ("subsetneq;", "\u{228A}"), ("subsetneqq;", "\u{2ACB}"), ("subsim;", "\u{2AC7}"), ("subsub;", "\u{2AD5}"), ("subsup;", "\u{2AD3}"), ("succ;", "\u{227B}"), ("succapprox;", "\u{2AB8}"), ("succcurlyeq;", "\u{227D}"), ("succeq;", "\u{2AB0}"), ("succnapprox;", "\u{2ABA}"), ("succneqq;", "\u{2AB6}"), ("succnsim;", "\u{22E9}"), ("succsim;", "\u{227F}"), ("sum;", "\u{2211}"), ("sung;", "\u{266A}"), ("sup1", "\u{B9}"), ("sup1;", "\u{B9}"), ("sup2", "\u{B2}"), ("sup2;", "\u{B2}"), ("sup3", "\u{B3}"), ("sup3;", "\u{B3}"), ("sup;", "\u{2283}"), ("supE;", "\u{2AC6}"), ("supdot;", "\u{2ABE}"), ("supdsub;", "\u{2AD8}"), ("supe;", "\u{2287}"), ("supedot;", "\u{2AC4}"), ("suphsol;", "\u{27C9}"), ("suphsub;", "\u{2AD7}"), ("suplarr;", "\u{297B}"), ("supmult;", "\u{2AC2}"), ("supnE;", "\u{2ACC}"), ("supne;", "\u{228B}"), ("supplus;", "\u{2AC0}"), ("supset;", "\u{2283}"), ("supseteq;", "\u{2287}"), ("supseteqq;", "\u{2AC6}"), ("supsetneq;", "\u{228B}"), ("supsetneqq;", "\u{2ACC}"), ("supsim;", "\u{2AC8}"), ("supsub;", "\u{2AD4}"), ("supsup;", "\u{2AD6}"), ("swArr;", "\u{21D9}"), ("swarhk;", "\u{2926}"), ("swarr;", "\u{2199}"), ("swarrow;", "\u{2199}"), ("swnwar;", "\u{292A}"), ("szlig", "\u{DF}"), ("szlig;", "\u{DF}"), ("target;", "\u{2316}"), ("tau;", "\u{3C4}"), ("tbrk;", "\u{23B4}"), ("tcaron;", "\u{165}"), ("tcedil;", "\u{163}"), ("tcy;", "\u{442}"), ("tdot;", "\u{20DB}"), ("telrec;", "\u{2315}"), ("tfr;", "\u{1D531}"), ("there4;", "\u{2234}"), ("therefore;", "\u{2234}"), ("theta;", "\u{3B8}"), ("thetasym;", "\u{3D1}"), ("thetav;", "\u{3D1}"), ("thickapprox;", "\u{2248}"), ("thicksim;", "\u{223C}"), ("thinsp;", "\u{2009}"), ("thkap;", "\u{2248}"), ("thksim;", "\u{223C}"), ("thorn", "\u{FE}"), ("thorn;", "\u{FE}"), ("tilde;", "\u{2DC}"), ("times", "\u{D7}"), ("times;", "\u{D7}"), ("timesb;", "\u{22A0}"), ("timesbar;", "\u{2A31}"), ("timesd;", "\u{2A30}"), ("tint;", "\u{222D}"), ("toea;", "\u{2928}"), ("top;", "\u{22A4}"), ("topbot;", "\u{2336}"), ("topcir;", "\u{2AF1}"), ("topf;", "\u{1D565}"), ("topfork;", "\u{2ADA}"), ("tosa;", "\u{2929}"), ("tprime;", "\u{2034}"), ("trade;", "\u{2122}"), ("triangle;", "\u{25B5}"), ("triangledown;", "\u{25BF}"), ("triangleleft;", "\u{25C3}"), ("trianglelefteq;", "\u{22B4}"), ("triangleq;", "\u{225C}"), ("triangleright;", "\u{25B9}"), ("trianglerighteq;", "\u{22B5}"), ("tridot;", "\u{25EC}"), ("trie;", "\u{225C}"), ("triminus;", "\u{2A3A}"), ("triplus;", "\u{2A39}"), ("trisb;", "\u{29CD}"), ("tritime;", "\u{2A3B}"), ("trpezium;", "\u{23E2}"), ("tscr;", "\u{1D4C9}"), ("tscy;", "\u{446}"), ("tshcy;", "\u{45B}"), ("tstrok;", "\u{167}"), ("twixt;", "\u{226C}"), ("twoheadleftarrow;", "\u{219E}"), ("twoheadrightarrow;", "\u{21A0}"), ("uArr;", "\u{21D1}"), ("uHar;", "\u{2963}"), ("uacute", "\u{FA}"), ("uacute;", "\u{FA}"), ("uarr;", "\u{2191}"), ("ubrcy;", "\u{45E}"), ("ubreve;", "\u{16D}"), ("ucirc", "\u{FB}"), ("ucirc;", "\u{FB}"), ("ucy;", "\u{443}"), ("udarr;", "\u{21C5}"), ("udblac;", "\u{171}"), ("udhar;", "\u{296E}"), ("ufisht;", "\u{297E}"), ("ufr;", "\u{1D532}"), ("ugrave", "\u{F9}"), ("ugrave;", "\u{F9}"), ("uharl;", "\u{21BF}"), ("uharr;", "\u{21BE}"), ("uhblk;", "\u{2580}"), ("ulcorn;", "\u{231C}"), ("ulcorner;", "\u{231C}"), ("ulcrop;", "\u{230F}"), ("ultri;", "\u{25F8}"), ("umacr;", "\u{16B}"), ("uml", "\u{A8}"), ("uml;", "\u{A8}"), ("uogon;", "\u{173}"), ("uopf;", "\u{1D566}"), ("uparrow;", "\u{2191}"), ("updownarrow;", "\u{2195}"), ("upharpoonleft;", "\u{21BF}"), ("upharpoonright;", "\u{21BE}"), ("uplus;", "\u{228E}"), ("upsi;", "\u{3C5}"), ("upsih;", "\u{3D2}"), ("upsilon;", "\u{3C5}"), ("upuparrows;", "\u{21C8}"), ("urcorn;", "\u{231D}"), ("urcorner;", "\u{231D}"), ("urcrop;", "\u{230E}"), ("uring;", "\u{16F}"), ("urtri;", "\u{25F9}"), ("uscr;", "\u{1D4CA}"), ("utdot;", "\u{22F0}"), ("utilde;", "\u{169}"), ("utri;", "\u{25B5}"), ("utrif;", "\u{25B4}"), ("uuarr;", "\u{21C8}"), ("uuml", "\u{FC}"), ("uuml;", "\u{FC}"), ("uwangle;", "\u{29A7}"), ("vArr;", "\u{21D5}"), ("vBar;", "\u{2AE8}"), ("vBarv;", "\u{2AE9}"), ("vDash;", "\u{22A8}"), ("vangrt;", "\u{299C}"), ("varepsilon;", "\u{3F5}"), ("varkappa;", "\u{3F0}"), ("varnothing;", "\u{2205}"), ("varphi;", "\u{3D5}"), ("varpi;", "\u{3D6}"), ("varpropto;", "\u{221D}"), ("varr;", "\u{2195}"), ("varrho;", "\u{3F1}"), ("varsigma;", "\u{3C2}"), ("varsubsetneq;", "\u{228A}\u{FE00}"), ("varsubsetneqq;", "\u{2ACB}\u{FE00}"), ("varsupsetneq;", "\u{228B}\u{FE00}"), ("varsupsetneqq;", "\u{2ACC}\u{FE00}"), ("vartheta;", "\u{3D1}"), ("vartriangleleft;", "\u{22B2}"), ("vartriangleright;", "\u{22B3}"), ("vcy;", "\u{432}"), ("vdash;", "\u{22A2}"), ("vee;", "\u{2228}"), ("veebar;", "\u{22BB}"), ("veeeq;", "\u{225A}"), ("vellip;", "\u{22EE}"), ("verbar;", "\u{7C}"), ("vert;", "\u{7C}"), ("vfr;", "\u{1D533}"), ("vltri;", "\u{22B2}"), ("vnsub;", "\u{2282}\u{20D2}"), ("vnsup;", "\u{2283}\u{20D2}"), ("vopf;", "\u{1D567}"), ("vprop;", "\u{221D}"), ("vrtri;", "\u{22B3}"), ("vscr;", "\u{1D4CB}"), ("vsubnE;", "\u{2ACB}\u{FE00}"), ("vsubne;", "\u{228A}\u{FE00}"), ("vsupnE;", "\u{2ACC}\u{FE00}"), ("vsupne;", "\u{228B}\u{FE00}"), ("vzigzag;", "\u{299A}"), ("wcirc;", "\u{175}"), ("wedbar;", "\u{2A5F}"), ("wedge;", "\u{2227}"), ("wedgeq;", "\u{2259}"), ("weierp;", "\u{2118}"), ("wfr;", "\u{1D534}"), ("wopf;", "\u{1D568}"), ("wp;", "\u{2118}"), ("wr;", "\u{2240}"), ("wreath;", "\u{2240}"), ("wscr;", "\u{1D4CC}"), ("xcap;", "\u{22C2}"), ("xcirc;", "\u{25EF}"), ("xcup;", "\u{22C3}"), ("xdtri;", "\u{25BD}"), ("xfr;", "\u{1D535}"), ("xhArr;", "\u{27FA}"), ("xharr;", "\u{27F7}"), ("xi;", "\u{3BE}"), ("xlArr;", "\u{27F8}"), ("xlarr;", "\u{27F5}"), ("xmap;", "\u{27FC}"), ("xnis;", "\u{22FB}"), ("xodot;", "\u{2A00}"), ("xopf;", "\u{1D569}"), ("xoplus;", "\u{2A01}"), ("xotime;", "\u{2A02}"), ("xrArr;", "\u{27F9}"), ("xrarr;", "\u{27F6}"), ("xscr;", "\u{1D4CD}"), ("xsqcup;", "\u{2A06}"), ("xuplus;", "\u{2A04}"), ("xutri;", "\u{25B3}"), ("xvee;", "\u{22C1}"), ("xwedge;", "\u{22C0}"), ("yacute", "\u{FD}"), ("yacute;", "\u{FD}"), ("yacy;", "\u{44F}"), ("ycirc;", "\u{177}"), ("ycy;", "\u{44B}"), ("yen", "\u{A5}"), ("yen;", "\u{A5}"), ("yfr;", "\u{1D536}"), ("yicy;", "\u{457}"), ("yopf;", "\u{1D56A}"), ("yscr;", "\u{1D4CE}"), ("yucy;", "\u{44E}"), ("yuml", "\u{FF}"), ("yuml;", "\u{FF}"), ("zacute;", "\u{17A}"), ("zcaron;", "\u{17E}"), ("zcy;", "\u{437}"), ("zdot;", "\u{17C}"), ("zeetrf;", "\u{2128}"), ("zeta;", "\u{3B6}"), ("zfr;", "\u{1D537}"), ("zhcy;", "\u{436}"), ("zigrarr;", "\u{21DD}"), ("zopf;", "\u{1D56B}"), ("zscr;", "\u{1D4CF}"), ("zwj;", "\u{200D}"), ("zwnj;", "\u{200C}"), ]; pub static HTML5_ENTITIES_REF: LazyLock> = LazyLock::new(|| HTML5_ENTITIES.iter().copied().collect()); hurl-7.1.0/src/html/escape.rs000064400000000000000000000032601046102023000141450ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ /// Replaces special characters "&", "<" and ">" to HTML-safe sequences. /// /// Both double quote (") and single quote (') characters are also /// translated. pub fn html_escape(text: &str) -> String { let mut output = String::new(); for c in text.chars() { match c { '&' => output.push_str("&"), '<' => output.push_str("<"), '>' => output.push_str(">"), '"' => output.push_str("""), '\'' => output.push_str("'"), _ => output.push(c), } } output } #[cfg(test)] mod tests { use super::html_escape; #[test] fn eval_html_escape() { let tests = [ ("foo", "foo"), ("", "<tag>"), ("foo & bar", "foo & bar"), ( "string with double quote: \"baz\"", "string with double quote: "baz"", ), ]; for (input, output) in tests.iter() { assert_eq!(html_escape(input), output.to_string()); } } } hurl-7.1.0/src/html/mod.rs000064400000000000000000000013341046102023000134640ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ mod entities; mod escape; mod unescape; pub use escape::html_escape; pub use unescape::html_unescape; hurl-7.1.0/src/html/unescape.rs000064400000000000000000000415471046102023000145220ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::{collections::HashMap, sync::LazyLock}; use regex::{Captures, Regex}; use crate::html::entities::HTML5_ENTITIES_REF; // Ref https://html.spec.whatwg.org/#decimal-character-reference-start-state static INVALID_CHAR: [(u32, &str); 34] = [ (0x00, "\u{fffd}"), // REPLACEMENT CHARACTER (0x0d, "\r"), // CARRIAGE RETURN (0x80, "\u{20ac}"), // EURO SIGN (0x81, "\u{81}"), // (0x82, "\u{201a}"), // SINGLE LOW-9 QUOTATION MARK (0x83, "\u{0192}"), // LATIN SMALL LETTER F WITH HOOK (0x84, "\u{201e}"), // DOUBLE LOW-9 QUOTATION MARK (0x85, "\u{2026}"), // HORIZONTAL ELLIPSIS (0x86, "\u{2020}"), // DAGGER (0x87, "\u{2021}"), // DOUBLE DAGGER (0x88, "\u{02c6}"), // MODIFIER LETTER CIRCUMFLEX ACCENT (0x89, "\u{2030}"), // PER MILLE SIGN (0x8a, "\u{0160}"), // LATIN CAPITAL LETTER S WITH CARON (0x8b, "\u{2039}"), // SINGLE LEFT-POINTING ANGLE QUOTATION MARK (0x8c, "\u{0152}"), // LATIN CAPITAL LIGATURE OE (0x8d, "\u{8d}"), // (0x8e, "\u{017d}"), // LATIN CAPITAL LETTER Z WITH CARON (0x8f, "\u{8f}"), // (0x90, "\u{90}"), // (0x91, "\u{2018}"), // LEFT SINGLE QUOTATION MARK (0x92, "\u{2019}"), // RIGHT SINGLE QUOTATION MARK (0x93, "\u{201c}"), // LEFT DOUBLE QUOTATION MARK (0x94, "\u{201d}"), // RIGHT DOUBLE QUOTATION MARK (0x95, "\u{2022}"), // BULLET (0x96, "\u{2013}"), // EN DASH (0x97, "\u{2014}"), // EM DASH (0x98, "\u{02dc}"), // SMALL TILDE (0x99, "\u{2122}"), // TRADE MARK SIGN (0x9a, "\u{0161}"), // LATIN SMALL LETTER S WITH CARON (0x9b, "\u{203a}"), // SINGLE RIGHT-POINTING ANGLE QUOTATION MARK (0x9c, "\u{0153}"), // LATIN SMALL LIGATURE OE (0x9d, "\u{9d}"), // (0x9e, "\u{017e}"), // LATIN SMALL LETTER Z WITH CARON (0x9f, "\u{0178}"), // LATIN CAPITAL LETTER Y WITH DIAERESIS ]; static INVALID_CHAR_REF: LazyLock> = LazyLock::new(|| INVALID_CHAR.iter().copied().collect()); static INVALID_CODEPOINTS: [u32; 126] = [ // 0x0001 to 0x0008 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, // 0x000E to 0x001F 0xe, 0xf, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, // 0x007F to 0x009F 0x7f, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, // 0xFDD0 to 0xFDEF 0xfdd0, 0xfdd1, 0xfdd2, 0xfdd3, 0xfdd4, 0xfdd5, 0xfdd6, 0xfdd7, 0xfdd8, 0xfdd9, 0xfdda, 0xfddb, 0xfddc, 0xfddd, 0xfdde, 0xfddf, 0xfde0, 0xfde1, 0xfde2, 0xfde3, 0xfde4, 0xfde5, 0xfde6, 0xfde7, 0xfde8, 0xfde9, 0xfdea, 0xfdeb, 0xfdec, 0xfded, 0xfdee, 0xfdef, // Others 0xb, 0xfffe, 0xffff, 0x1fffe, 0x1ffff, 0x2fffe, 0x2ffff, 0x3fffe, 0x3ffff, 0x4fffe, 0x4ffff, 0x5fffe, 0x5ffff, 0x6fffe, 0x6ffff, 0x7fffe, 0x7ffff, 0x8fffe, 0x8ffff, 0x9fffe, 0x9ffff, 0xafffe, 0xaffff, 0xbfffe, 0xbffff, 0xcfffe, 0xcffff, 0xdfffe, 0xdffff, 0xefffe, 0xeffff, 0xffffe, 0xfffff, 0x10fffe, 0x10ffff, ]; static CHAR_REF: LazyLock = LazyLock::new(|| { Regex::new(concat!( r"&(#\d+;?", r"|#[xX][\da-fA-F]+;?", r"|[^\t\n\f <&#;]{1,32};?)", )) .unwrap() }); /// Convert all named and numeric character references (e.g. >, >, /// &x3e;) in the string `text` to the corresponding unicode characters. /// This function uses the rules defined by the HTML 5 standard /// for both valid and invalid character references, and the list of /// HTML 5 named character references defined in html.entities.html5. /// /// The code is adapted from the Python standard library: /// /// /// See MDN decoder tool: pub fn html_unescape(text: &str) -> String { if text.chars().any(|c| c == '&') { CHAR_REF .replace_all(text, |caps: &Captures| { let s = &caps[1]; let s0 = s.chars().next().unwrap(); if s0 == '#' { // Numeric charref let s1 = s.chars().nth(1).unwrap(); let num = if s1 == 'x' || s1 == 'X' { let val = s[2..].trim_end_matches(';'); match u32::from_str_radix(val, 16) { Ok(val) => val, Err(_) => return "\u{FFFD}".to_string(), } } else { let val = s[1..].trim_end_matches(';'); match val.parse::() { Ok(val) => val, Err(_) => return "\u{FFFD}".to_string(), } }; if let Some(char) = INVALID_CHAR_REF.get(&num) { return char.to_string(); } if (0xD800..=0xDFFF).contains(&num) || num > 0x10FFFF { return "\u{FFFD}".to_string(); } if INVALID_CODEPOINTS.contains(&num) { return String::new(); } char::from_u32(num).unwrap().to_string() } else { if let Some(entity) = HTML5_ENTITIES_REF.get(s) { return entity.to_string(); } // Find the longest matching name (as defined by the standard) for x in (1..s.len()).rev() { let name = &s[..x]; if let Some(entity) = HTML5_ENTITIES_REF.get(name) { return format!("{}{}", entity, &s[x..]); } } format!("&{s}") } }) .to_string() } else { text.to_string() } } #[cfg(test)] mod tests { use super::html_unescape; /// Extracts from Python test suites: https://github.com/python/cpython/blob/main/Lib/test/test_html.py #[test] fn test_html_unescape() { fn check(text: &str, expected: &str) { assert_eq!(html_unescape(text), expected.to_string()); } fn check_num(num: usize, expected: &str) { let text = format!("&#{num}"); check(&text, expected); let text = format!("&#{num};"); check(&text, expected); let text = format!("&#x{num:x}"); check(&text, expected); let text = format!("&#x{num:x};"); check(&text, expected); } check("Hurl⇄", "Hurl⇄"); // Check simple check( "Foo © bar 𝌆 baz ☃ qux", "Foo © bar 𝌆 baz ☃ qux", ); // Check text with no character references check("no character references", "no character references"); // Check & followed by invalid chars check("&\n&\t& &&", "&\n&\t& &&"); // Check & followed by numbers and letters check("&0 &9 &a &0; &9; &a;", "&0 &9 &a &0; &9; &a;"); // Check incomplete entities at the end of the string for x in ["&", "&#", "&#x", "&#X", "&#y", "&#xy", "&#Xy"].iter() { check(x, x); check(&format!("{x};"), &format!("{x};")); } // Check several combinations of numeric character references, // possibly followed by different characters // Format Ӓ (without ending semi-colon) for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#{num}"), &format!("{char}")); check(&format!("&#{num} "), &format!("{char} ")); check(&format!("&#{num}X"), &format!("{char}X")); } // Format Ӓ (without ending semi-colon) for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#{num:07}"), &format!("{char}")); check(&format!("&#{num:07} "), &format!("{char} ")); check(&format!("&#{num:07}X"), &format!("{char}X")); } // Format Ӓ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#{num};"), &format!("{char}")); check(&format!("&#{num}; "), &format!("{char} ")); check(&format!("&#{num};X"), &format!("{char}X")); } // Format Ӓ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#{num:07};"), &format!("{char}")); check(&format!("&#{num:07}; "), &format!("{char} ")); check(&format!("&#{num:07};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:x}"), &format!("{char}")); check(&format!("&#x{num:x} "), &format!("{char} ")); check(&format!("&#x{num:x}X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:06x}"), &format!("{char}")); check(&format!("&#x{num:06x} "), &format!("{char} ")); check(&format!("&#x{num:06x}X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:x};"), &format!("{char}")); check(&format!("&#x{num:x}; "), &format!("{char} ")); check(&format!("&#x{num:x};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:06x};"), &format!("{char}")); check(&format!("&#x{num:06x}; "), &format!("{char} ")); check(&format!("&#x{num:06x};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:X}"), &format!("{char}")); check(&format!("&#x{num:X} "), &format!("{char} ")); check(&format!("&#x{num:X}X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:06X}"), &format!("{char}")); check(&format!("&#x{num:06X} "), &format!("{char} ")); check(&format!("&#x{num:06X}X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:X};"), &format!("{char}")); check(&format!("&#x{num:X}; "), &format!("{char} ")); check(&format!("&#x{num:X};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:06X};"), &format!("{char}")); check(&format!("&#x{num:06X}; "), &format!("{char} ")); check(&format!("&#x{num:06X};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#X{num:x};"), &format!("{char}")); check(&format!("&#X{num:x}; "), &format!("{char} ")); check(&format!("&#X{num:x};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#X{num:06x};"), &format!("{char}")); check(&format!("&#X{num:06x}; "), &format!("{char} ")); check(&format!("&#X{num:06x};X"), &format!("{char}X")); } // Check invalid code points for cp in [0xD800, 0xDB00, 0xDC00, 0xDFFF, 0x110000] { check_num(cp, "\u{FFFD}"); } // Check more invalid code points for cp in [0x1, 0xb, 0xe, 0x7f, 0xfffe, 0xffff, 0x10fffe, 0x10ffff] { check_num(cp, ""); } // Check invalid numbers for (num, ch) in [(0x0d, "\r"), (0x80, "\u{20ac}"), (0x95, "\u{2022}")] { check_num(num, ch); } // Check small numbers check_num(0, "\u{FFFD}"); check_num(9, "\t"); // Check a big number check_num(1000000000000000000, "\u{FFFD}"); // Check that multiple trailing semicolons are handled correctly for e in ["";", "";", "";", "";"] { check(e, "\";"); } // Check that semicolons in the middle don't create problems for e in [""quot;", ""quot;", ""quot;", ""quot;"] { check(e, "\"quot;"); } // Check triple adjacent charrefs for e in [""", """, """, """] { // check(&e.repeat(3), "\"\"\""); check(&format!("{e};").repeat(3), "\"\"\""); } // Check that the case is respected for e in ["&", "&", "&", "&"] { check(e, "&"); } for e in ["&Amp", "&Amp;"] { check(e, e); } // Check that nonexistent named entities are returned unchanged check("&svadilfari;", "&svadilfari;"); // The following examples are in the html5 specs check("¬it", "¬it"); check("¬it;", "¬it;"); check("¬in", "¬in"); check("∉", "∉"); // A similar example with a long name check( "¬ReallyAnExistingNamedCharacterReference;", "¬ReallyAnExistingNamedCharacterReference;", ); // Longest valid name check("∳", "∳"); // Check a charref that maps to two unicode chars check("∾̳", "\u{223e}\u{333}"); check("&acE", "&acE"); // See Python #12888 check(&"{ ".repeat(1050), &"{ ".repeat(1050)); // See Python #15156 check( "ÉricÉric&alphacentauriαcentauri", "ÉricÉric&alphacentauriαcentauri", ); check("&co;", "&co;"); } } hurl-7.1.0/src/http/call.rs000064400000000000000000000023461046102023000136370ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use super::request::Request; use super::response::Response; use super::timings::Timings; /// Holds an HTTP request and the corresponding HTTP response. /// The request and responses are the runtime, evaluated data created by an HTTP exchange. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Call { /// The real HTTP request (vs the specified request in a Hurl file source) pub request: Request, /// The real HTTP response (vs the specified request in a Hurl file source) pub response: Response, /// Timings of the exchange, see pub timings: Timings, } hurl-7.1.0/src/http/certificate.rs000064400000000000000000000215321046102023000152040ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::collections::HashMap; use chrono::{DateTime, NaiveDateTime, Utc}; use super::easy_ext::CertInfo; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Certificate { pub subject: String, pub issuer: String, pub start_date: DateTime, pub expire_date: DateTime, pub serial_number: String, } impl TryFrom for Certificate { type Error = String; /// parse `cert_info` /// support different "formats" in cert info /// - attribute name: "Start date" vs "Start Date" /// - date format: "Jan 10 08:29:52 2023 GMT" vs "2023-01-10 08:29:52 GMT" fn try_from(cert_info: CertInfo) -> Result { let attributes = parse_attributes(&cert_info.data); let subject = parse_subject(&attributes)?; let issuer = parse_issuer(&attributes)?; let start_date = parse_start_date(&attributes)?; let expire_date = parse_expire_date(&attributes)?; let serial_number = parse_serial_number(&attributes)?; Ok(Certificate { subject, issuer, start_date, expire_date, serial_number, }) } } /// Parses certificate's subject attribute. /// /// TODO: we're exposing the subject and issuer directly from libcurl. In the certificate, these /// properties are list of pair of key-value. /// Through libcurl, these lists are serialized to a string: /// /// Example: /// vec![("C","US"),("O","Google Trust Services LLC"),("CN","GTS Root R1"))] => /// "C = US, O = Google Trust Services LLC, CN = GTS Root R1" /// /// We should normalize the serialization (use 'A = B' or 'A=B') to always have the same issuer/ /// subject given a certain certificate. Actually the value can differ on different platforms, for /// a given certificate. /// /// See: /// - /// - https://curl.se/mail/lib-2024-06/0013.html fn parse_subject(attributes: &HashMap) -> Result { match attributes.get("subject") { None => Err(format!("missing Subject attribute in {attributes:?}")), Some(value) => Ok(value.clone()), } } /// Parses certificate's issuer attribute. fn parse_issuer(attributes: &HashMap) -> Result { match attributes.get("issuer") { None => Err(format!("missing Issuer attribute in {attributes:?}")), Some(value) => Ok(value.clone()), } } fn parse_start_date(attributes: &HashMap) -> Result, String> { match attributes.get("start date") { None => Err(format!("missing start date attribute in {attributes:?}")), Some(value) => Ok(parse_date(value)?), } } fn parse_expire_date(attributes: &HashMap) -> Result, String> { match attributes.get("expire date") { None => Err("missing expire date attribute".to_string()), Some(value) => Ok(parse_date(value)?), } } fn parse_date(value: &str) -> Result, String> { let naive_date_time = match NaiveDateTime::parse_from_str(value, "%b %d %H:%M:%S %Y GMT") { Ok(d) => d, Err(_) => NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S GMT") .map_err(|_| format!("can not parse date <{value}>"))?, }; Ok(naive_date_time.and_local_timezone(Utc).unwrap()) } fn parse_serial_number(attributes: &HashMap) -> Result { let value = attributes .get("serial number") .cloned() .ok_or(format!("Missing serial number attribute in {attributes:?}"))?; let normalized_value = if value.contains(':') { value .split(':') .filter(|e| !e.is_empty()) .collect::>() .join(":") } else { value .chars() .collect::>() .chunks(2) .map(|c| c.iter().collect::()) .collect::>() .join(":") }; Ok(normalized_value) } fn parse_attributes(data: &Vec) -> HashMap { let mut map = HashMap::new(); for s in data { if let Some((name, value)) = parse_attribute(s) { map.insert(name.to_lowercase(), value); } } map } fn parse_attribute(s: &str) -> Option<(String, String)> { if let Some(index) = s.find(':') { let (name, value) = s.split_at(index); Some((name.to_string(), value[1..].to_string())) } else { None } } #[cfg(test)] mod tests { use super::*; use crate::http::certificate::Certificate; use crate::http::easy_ext::CertInfo; #[test] fn test_parse_subject() { let mut attributes = HashMap::new(); attributes.insert( "subject".to_string(), "C=US, ST=Denial, L=Springfield, O=Dis, CN=localhost".to_string(), ); assert_eq!( parse_subject(&attributes).unwrap(), "C=US, ST=Denial, L=Springfield, O=Dis, CN=localhost".to_string() ); } #[test] fn test_parse_start_date() { let mut attributes = HashMap::new(); attributes.insert( "start date".to_string(), "Jan 10 08:29:52 2023 GMT".to_string(), ); assert_eq!( parse_start_date(&attributes).unwrap(), chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") .unwrap() .with_timezone(&chrono::Utc) ); let mut attributes = HashMap::new(); attributes.insert( "start date".to_string(), "2023-01-10 08:29:52 GMT".to_string(), ); assert_eq!( parse_start_date(&attributes).unwrap(), chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") .unwrap() .with_timezone(&chrono::Utc) ); } #[test] fn test_parse_serial_number() { let mut attributes = HashMap::new(); attributes.insert( "serial number".to_string(), "1e:e8:b1:7f:1b:64:d8:d6:b3:de:87:01:03:d2:a4:f5:33:53:5a:b0:".to_string(), ); assert_eq!( parse_serial_number(&attributes).unwrap(), "1e:e8:b1:7f:1b:64:d8:d6:b3:de:87:01:03:d2:a4:f5:33:53:5a:b0".to_string() ); let mut attributes = HashMap::new(); attributes.insert( "serial number".to_string(), "1ee8b17f1b64d8d6b3de870103d2a4f533535ab0".to_string(), ); assert_eq!( parse_serial_number(&attributes).unwrap(), "1e:e8:b1:7f:1b:64:d8:d6:b3:de:87:01:03:d2:a4:f5:33:53:5a:b0".to_string() ); } #[test] fn test_try_from() { assert_eq!( Certificate::try_from(CertInfo { data: vec![ "Subject:C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost" .to_string(), "Issuer:C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost" .to_string(), "Serial Number:1ee8b17f1b64d8d6b3de870103d2a4f533535ab0".to_string(), "Start date:Jan 10 08:29:52 2023 GMT".to_string(), "Expire date:Oct 30 08:29:52 2025 GMT".to_string(), ] }) .unwrap(), Certificate { subject: "C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost" .to_string(), issuer: "C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost".to_string(), start_date: chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") .unwrap() .with_timezone(&chrono::Utc), expire_date: chrono::DateTime::parse_from_rfc2822("Thu, 30 Oct 2025 08:29:52 GMT") .unwrap() .with_timezone(&chrono::Utc), serial_number: "1e:e8:b1:7f:1b:64:d8:d6:b3:de:87:01:03:d2:a4:f5:33:53:5a:b0" .to_string() } ); assert_eq!( Certificate::try_from(CertInfo { data: vec![] }) .err() .unwrap(), "missing Subject attribute in {}".to_string() ); } } hurl-7.1.0/src/http/client.rs000064400000000000000000001354011046102023000142010ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::collections::HashMap; use std::str; use std::str::FromStr; use std::time::Instant; use base64::engine::general_purpose; use base64::Engine; use chrono::Utc; use curl::easy::{List, NetRc, SslOpt}; use curl::{easy, Error, Version}; use hurl_core::types::Count; use super::call::Call; use super::certificate::Certificate; use super::cookie_store::{Cookie, CookieStore}; use super::curl_cmd::CurlCmd; use super::debug; use super::easy_ext; use super::error::HttpError; use super::header::{ Header, HeaderVec, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, COOKIE, EXPECT, LOCATION, USER_AGENT, }; use super::ip::IpAddr; use super::options::{ClientOptions, Verbosity}; use super::param::Param; use super::request::{IpResolve, Request, RequestedHttpVersion}; use super::request_cookie::RequestCookie; use super::request_spec::{Body, FileParam, Method, MultipartParam, RequestSpec}; use super::response::{HttpVersion, Response}; use super::timings::Timings; use super::url::Url; use crate::runner::Output; use crate::util::logger::Logger; use crate::util::path::ContextDir; /// Defines an HTTP client to execute HTTP requests. /// /// Most of the methods are delegated to libcurl functions, while some /// features are implemented "by hand" (like retry, redirection etc...) #[derive(Debug)] pub struct Client { /// The handle to libcurl binding handle: easy::Easy, /// HTTP version support http2: bool, http3: bool, /// Certificates cache to get SSL certificates on reused libcurl connections. certificates: HashMap, } impl Client { /// Creates HTTP Hurl client. pub fn new() -> Client { let handle = easy::Easy::new(); let version = Version::get(); Client { handle, http2: version.feature_http2(), http3: version.feature_http3(), certificates: HashMap::new(), } } /// Executes an HTTP request `request_spec`, optionally follows redirection and returns a list of [`Call`]. pub fn execute_with_redirect( &mut self, request_spec: &RequestSpec, options: &ClientOptions, logger: &mut Logger, ) -> Result, HttpError> { let mut calls = vec![]; let original_url = &request_spec.url; let mut request_spec = request_spec.clone(); let mut options = options.clone(); // Unfortunately, follow-location feature from libcurl can not be used as libcurl returns a // single list of headers for the 2 responses and Hurl needs to keep every header of every // response. let mut redirect_count = 0; loop { let call = self.execute(&request_spec, &options, logger)?; // If we don't follow redirection, we can early exit here. if !options.follow_location { calls.push(call); break; } let request_url = call.request.url.clone(); let status = call.response.status; let redirect_url = self.follow_location(&request_url, &call.response)?; calls.push(call); if redirect_url.is_none() { break; } let redirect_url = redirect_url.unwrap(); logger.debug(""); logger.debug(&format!("=> Redirect to {redirect_url}")); logger.debug(""); redirect_count += 1; if let Count::Finite(max_redirect) = options.max_redirect { if redirect_count > max_redirect { return Err(HttpError::TooManyRedirect); } }; let redirect_method = redirect_method(status, &request_spec.method); let mut headers = request_spec.headers; // When following redirection, we filter `Authorization` and `Set-Cookie` headers if the // hostname changes unless the user explicitly trusts the redirected host with `--location-trusted`. // : // // > By default, libcurl only sends Authentication: or explicitly set Cookie: headers // > to the initial host given in the original URL, to avoid leaking username + password // > to other sites. if should_strip_credentials_on_redirect( original_url, &redirect_url, options.follow_location_trusted, ) { headers.retain(|h| !h.name_eq(AUTHORIZATION)); headers.retain(|h| !h.name_eq(COOKIE)); options.user = None; } // If the request method has changed due to redirection, the body is dropped from the // request, otherwise we keep it. We follow libcurl implementation : // // > When libcurl switches method to GET, it then uses that method without sending any // > request body. If it does not change the method, it sends the subsequent request the // > same way as the previous one; including the request body if one was provided. let (form, multipart, body, implicit_content_type) = if redirect_method != request_spec.method { (vec![], vec![], Body::Binary(vec![]), None) } else { ( request_spec.form, request_spec.multipart, request_spec.body, request_spec.implicit_content_type, ) }; request_spec = RequestSpec { method: redirect_method, url: redirect_url, headers, querystring: vec![], form, multipart, cookies: request_spec.cookies, body, implicit_content_type, }; } Ok(calls) } /// Executes an HTTP request `request_spec`, without following redirection and returns a /// pair of [`Call`]. pub fn execute( &mut self, request_spec: &RequestSpec, options: &ClientOptions, logger: &mut Logger, ) -> Result { // The handle can be mutated in this function: to start from a clean state, we reset it // prior to everything. self.handle.reset(); let (url, method) = self.configure(request_spec, options, logger)?; let start = Instant::now(); let start_dt = Utc::now(); let verbose = options.verbosity.is_some(); let very_verbose = options.verbosity == Some(Verbosity::VeryVerbose); let mut request_headers = HeaderVec::new(); let mut status_lines = None; let mut response_headers = vec![]; let has_body_data = !request_spec.body.bytes().is_empty() || !request_spec.form.is_empty() || !request_spec.multipart.is_empty(); // `request_body` are request body bytes computed by libcurl (the real bytes sent over the wire) // whereas`request_spec_body` are request body bytes provided by Hurl user. For instance, if user uses // a [FormParam] section, `request_body` is empty whereas libcurl sent a url-form encoded list // of key-value. let mut request_body = Vec::::new(); let mut response_body = Vec::::new(); { let mut transfer = self.handle.transfer(); transfer.debug_function(|info_type, data| match info_type { // Return all request headers (not one by one) easy::InfoType::HeaderOut => { let lines = split_lines(data); // Extracts request headers from libcurl debug info. // First line is method/path/version line, last line is empty for line in &lines[1..lines.len() - 1] { if let Some(header) = Header::parse(line) { request_headers.push(header); } } // Logs method, version and request headers now. if verbose { logger.debug_method_version_out(&lines[0]); let headers = request_headers .iter() .map(|h| (h.name.as_str(), h.value.as_str())) .collect::>(); logger.debug_headers_out(&headers); } // If we don't send any data, we log an empty body here instead of relying on // libcurl computing body in `easy::InfoType::DataOut` because libcurl doesn't // call `easy::InfoType::DataOut` if there is no data to send. if !has_body_data && very_verbose { logger.debug_important("Request body:"); debug::log_body(&[], &request_headers, true, logger); } } // We use this callback to get the real body bytes sent by libcurl and logs request // body chunks. easy::InfoType::DataOut => { if very_verbose { logger.debug_important("Request body:"); debug::log_body(data, &request_headers, true, logger); } // Constructs request body from libcurl debug info. request_body.extend(data); } // Curl debug logs easy::InfoType::Text => { let len = data.len(); if very_verbose && len > 0 { let text = str::from_utf8(&data[..len - 1]); if let Ok(text) = text { logger.debug_curl(text); } } } _ => {} })?; transfer.header_function(|h| { if let Some(s) = decode_header(h) { if s.starts_with("HTTP/") { status_lines = Some(s); } else { response_headers.push(s); } } true })?; transfer.write_function(|data| { response_body.extend(data); Ok(data.len()) })?; if let Err(e) = transfer.perform() { let code = e.code() as i32; // due to windows build let description = match e.extra_description() { None => e.description().to_string(), Some(s) => s.to_string(), }; return Err(HttpError::Libcurl { code, description }); } } // We perform an additional check on the response size if maximum filesize is specified // because curl can fail to do this under certain circumstances. // See: // - // - // > Note: before curl 8.4.0, when the file size is not known prior to download, for such files // > this option has no effect even if the file transfer ends up being larger than this given limit. if let Some(max_filesize) = options.max_filesize { if response_body.len() as u64 > max_filesize { return Err(HttpError::AllowedResponseSizeExceeded(max_filesize)); } } let status = self.handle.response_code()?; let version = match &status_lines { Some(status_line) => self.parse_response_version(status_line)?, None => return Err(HttpError::CouldNotParseResponse), }; let headers = self.parse_response_headers(&response_headers); let length = response_body.len(); let certificate = self.cert_info(logger)?; let duration = start.elapsed(); let stop_dt = start_dt + duration; let timings = Timings::new(&mut self.handle, start_dt, stop_dt); let url = Url::from_str(&url)?; let ip_addr = self.primary_ip()?; let request = Request::new( &method.to_string(), url.clone(), request_headers, request_body, ); let response = Response::new( version, status, headers, response_body, duration, url, certificate, ip_addr, ); if verbose { // FIXME: the cast to u64 seems not necessary. // If we dont cast from u128 and try to format! or println! // we have a segfault on Alpine Docker images and Rust 1.68.0, whereas it was // ok with Rust >= 1.67.0. let duration = duration.as_millis() as u64; logger.debug_important(&format!( "Response: (received {length} bytes in {duration} ms)" )); logger.debug(""); // FIXME: Explain why there may be multiple status line status_lines .iter() .filter(|s| s.starts_with("HTTP/")) .for_each(|s| logger.debug_status_version_in(s.trim())); let headers = response .headers .iter() .map(|h| (h.name.as_str(), h.value.as_str())) .collect::>(); logger.debug_headers_in(&headers); if very_verbose { logger.debug_important("Response body:"); response.log_body(true, logger); logger.debug(""); timings.log(logger); } } Ok(Call { request, response, timings, }) } /// Configure libcurl handle to send a `request_spec`, using `options`. /// If configuration is successful, returns a tuple of the concrete requested URL and method. fn configure( &mut self, request_spec: &RequestSpec, options: &ClientOptions, logger: &mut Logger, ) -> Result<(String, Method), HttpError> { // Activates cookie engine. // See // > It also enables the cookie engine, making libcurl parse and send cookies on subsequent // > requests with this handle. // > By passing the empty string ("") to this option, you enable the cookie // > engine without reading any initial cookies. self.handle .cookie_file(options.cookie_input_file.clone().unwrap_or_default())?; // We check libcurl HTTP version support. let http_version = options.http_version; if (http_version == RequestedHttpVersion::Http2 && !self.http2) || (http_version == RequestedHttpVersion::Http3 && !self.http3) { return Err(HttpError::UnsupportedHttpVersion(http_version)); } if !options.allow_reuse { logger.debug("Force refreshing connections because requested HTTP version change"); } self.handle.fresh_connect(!options.allow_reuse)?; self.handle.forbid_reuse(!options.allow_reuse)?; self.handle.http_version(options.http_version.into())?; self.handle.ip_resolve(options.ip_resolve.into())?; // Activates the access of certificates info chain after a transfer has been executed. self.handle.certinfo(true)?; if !options.connects_to.is_empty() { let connects = to_list(&options.connects_to)?; self.handle.connect_to(connects)?; } if !options.resolves.is_empty() { let resolves = to_list(&options.resolves)?; self.handle.resolve(resolves)?; } self.handle.ssl_verify_host(!options.insecure)?; self.handle.ssl_verify_peer(!options.insecure)?; if let Some(cacert_file) = &options.cacert_file { self.handle.cainfo(cacert_file)?; self.handle.ssl_cert_type("PEM")?; } if let Some(client_cert_file) = &options.client_cert_file { match parse_cert_password(client_cert_file) { (cert, Some(password)) => { self.handle.ssl_cert(cert)?; self.handle.key_password(&password)?; } (cert, None) => { self.handle.ssl_cert(cert)?; } } self.handle.ssl_cert_type("PEM")?; } if let Some(client_key_file) = &options.client_key_file { self.handle.ssl_key(client_key_file)?; self.handle.ssl_cert_type("PEM")?; } self.handle.path_as_is(options.path_as_is)?; if let Some(proxy) = &options.proxy { self.handle.proxy(proxy)?; } if let Some(no_proxy) = &options.no_proxy { self.handle.noproxy(no_proxy)?; } if let Some(unix_socket) = &options.unix_socket { self.handle.unix_socket(unix_socket)?; } if let Some(filename) = &options.netrc_file { easy_ext::netrc_file(&mut self.handle, filename)?; self.handle.netrc(if options.netrc_optional { NetRc::Optional } else { NetRc::Required })?; } else if options.netrc_optional { self.handle.netrc(NetRc::Optional)?; } else if options.netrc { self.handle.netrc(NetRc::Required)?; } self.handle.timeout(options.timeout)?; self.handle.connect_timeout(options.connect_timeout)?; if let Some(max_filesize) = options.max_filesize { self.handle.max_filesize(max_filesize)?; } if let Some(max_recv_speed) = options.max_recv_speed { self.handle.max_recv_speed(max_recv_speed.0)?; } if let Some(max_send_speed) = options.max_send_speed { self.handle.max_send_speed(max_send_speed.0)?; } if let Some(pinned_pub_key) = &options.pinned_pub_key { self.handle.pinned_public_key(pinned_pub_key)?; } if options.ntlm || options.negotiate { let mut auth = easy::Auth::new(); if options.ntlm { auth.ntlm(true); } if options.negotiate { auth.gssnegotiate(true); } self.handle.http_auth(&auth)?; } self.set_ssl_options(options.ssl_no_revoke)?; let url = self.generate_url(&request_spec.url, &request_spec.querystring); self.handle.url(url.as_str())?; let method = &request_spec.method; self.set_method(method)?; self.set_cookies(&request_spec.cookies)?; self.set_form(&request_spec.form)?; self.set_multipart(&request_spec.multipart)?; let request_spec_body = &request_spec.body.bytes(); self.set_body(request_spec_body)?; // TODO: do we want to manage the headers with no content? There are two type of no-content // headers: `foo:` and `foo;`. The first one can be used to remove libcurl headers (`Host:`) // while the second one is used to send an empty header. // See let options_headers = options .headers .iter() .map(|h| h.as_str()) .collect::>(); let headers = &request_spec.headers.with_raw_headers(&options_headers); self.set_headers( headers, request_spec.implicit_content_type.as_deref(), options, )?; if let Some(aws_sigv4) = &options.aws_sigv4 { if let Err(e) = self.handle.aws_sigv4(aws_sigv4.as_str()) { return match e.code() { curl_sys::CURLE_UNKNOWN_OPTION => Err(HttpError::LibcurlUnknownOption { option: "aws-sigv4".to_string(), minimum_version: "7.75.0".to_string(), }), _ => Err(e.into()), }; } } if *method == Method("HEAD".to_string()) { self.handle.nobody(true)?; } // We force libcurl verbose mode regardless of Hurl verbose option to be able to capture HTTP // request headers in libcurl `debug_function`. That's the only way to get access to the // outgoing headers. We call this at the end of the libcurl handle configuration to avoid // unwanted noisy logs from curl (see ) self.handle.verbose(true)?; Ok((url, method.clone())) } /// Generates URL. fn generate_url(&mut self, url: &Url, params: &[Param]) -> String { let url = url.raw(); if params.is_empty() { url } else { let url = if url.ends_with('?') { url } else if url.contains('?') { format!("{url}&") } else { format!("{url}?") }; let s = self.url_encode_params(params); format!("{url}{s}") } } /// Sets HTTP method. fn set_method(&mut self, method: &Method) -> Result<(), HttpError> { self.handle.custom_request(method.to_string().as_str())?; Ok(()) } /// Sets HTTP headers. fn set_headers( &mut self, headers: &HeaderVec, implicit_content_type: Option<&str>, options: &ClientOptions, ) -> Result<(), HttpError> { let mut list = headers.to_curl_headers()?; // If request has no `Content-Type` header, we set it if the content type has been set // implicitly on this request. if !headers.contains_key(CONTENT_TYPE) { if let Some(s) = implicit_content_type { list.append(&format!("{CONTENT_TYPE}: {s}"))?; } else { // We remove default `Content-Type` headers added by curl because we want to // explicitly manage this header. // For instance, with --data option, curl will send a `Content-type: application/x-www-form-urlencoded` // header. From , we can delete // the headers added by libcurl by adding a header with no content. list.append(&format!("{CONTENT_TYPE}:"))?; } } // Workaround for libcurl issue : // When Hurl explicitly sets `Expect:` to remove the header, libcurl will generate // `SignedHeaders` that include `expect` even though the header is not present, causing // some APIs to reject the request. // Therefore, we only remove this header when not in aws_sigv4 mode. if !headers.contains_key(EXPECT) && options.aws_sigv4.is_none() { // We remove default Expect headers added by curl because we want to explicitly manage // this header. list.append(&format!("{EXPECT}:"))?; } if !headers.contains_key(USER_AGENT) { let user_agent = match options.user_agent { Some(ref u) => u.clone(), None => { let pkg_version = env!("CARGO_PKG_VERSION"); format!("hurl/{pkg_version}") } }; list.append(&format!("{USER_AGENT}: {user_agent}"))?; } if let Some(user) = &options.user { if options.aws_sigv4.is_some() || options.ntlm || options.negotiate { // curl's aws_sigv4 support needs to know the username and password for the // request, as it uses those values to calculate the Authorization header for the // AWS V4 signature. // // --ntlm requires a username and password to be provided in order to complete the // authentication process. With curl, this would be `-u username:password` // // --negotiate requires a username and password, though they are not used. // From the curl man page: // > When using this option, you must also provide a fake `-u, --user` option to // > activate the authentication code properly. Sending a '-u :' is enough, as the // > username and password from the `-u, --user` option are not actually used. if let Some((username, password)) = user.split_once(':') { self.handle.username(username)?; self.handle.password(password)?; } } else { let user = user.as_bytes(); let authorization = general_purpose::STANDARD.encode(user); if !headers.contains_key(AUTHORIZATION) { list.append(&format!("{AUTHORIZATION}: Basic {authorization}"))?; } } } if options.compressed && !headers.contains_key(ACCEPT_ENCODING) { list.append(&format!("{ACCEPT_ENCODING}: gzip, deflate, br"))?; } self.handle.http_headers(list)?; Ok(()) } /// Sets request cookies. fn set_cookies(&mut self, cookies: &[RequestCookie]) -> Result<(), HttpError> { let s = cookies .iter() .map(|c| c.to_string()) .collect::>() .join("; "); if !s.is_empty() { self.handle.cookie(s.as_str())?; } Ok(()) } /// Sets form params. fn set_form(&mut self, params: &[Param]) -> Result<(), HttpError> { if !params.is_empty() { let s = self.url_encode_params(params); self.handle.post_fields_copy(s.as_bytes())?; } Ok(()) } /// Sets multipart form data. fn set_multipart(&mut self, params: &[MultipartParam]) -> Result<(), HttpError> { if !params.is_empty() { let mut form = easy::Form::new(); for param in params { match param { MultipartParam::Param(Param { name, value }) => { form.part(name).contents(value.as_bytes()).add()?; } MultipartParam::FileParam(FileParam { name, filename, data, content_type, }) => form .part(name) .buffer(filename, data.clone()) .content_type(content_type) .add()?, } } self.handle.httppost(form)?; } Ok(()) } /// Sets request body. fn set_body(&mut self, data: &[u8]) -> Result<(), HttpError> { if !data.is_empty() { self.handle.post(true)?; self.handle.post_fields_copy(data)?; } Ok(()) } /// Sets SSL options fn set_ssl_options(&mut self, no_revoke: bool) -> Result<(), HttpError> { let mut ssl_opt = SslOpt::new(); ssl_opt.no_revoke(no_revoke); self.handle.ssl_options(&ssl_opt)?; Ok(()) } /// URL encodes parameters. fn url_encode_params(&mut self, params: &[Param]) -> String { params .iter() .map(|p| { let value = self.handle.url_encode(p.value.as_bytes()); format!("{}={}", p.name, value) }) .collect::>() .join("&") } /// Parses HTTP response version. fn parse_response_version(&mut self, line: &str) -> Result { if line.starts_with("HTTP/1.0") { Ok(HttpVersion::Http10) } else if line.starts_with("HTTP/1.1") { Ok(HttpVersion::Http11) } else if line.starts_with("HTTP/2") { Ok(HttpVersion::Http2) } else if line.starts_with("HTTP/3") { Ok(HttpVersion::Http3) } else { Err(HttpError::CouldNotParseResponse) } } /// Parse headers from libcurl responses. fn parse_response_headers(&mut self, lines: &[String]) -> HeaderVec { let mut headers = HeaderVec::new(); for line in lines { if let Some(header) = Header::parse(line) { headers.push(header); } } headers } /// Get the IP address of the last connection from libcurl fn primary_ip(&mut self) -> Result { match self.handle.primary_ip()? { Some(ip) => Ok(IpAddr::new(ip.to_string())), None => Err(HttpError::NoPrimaryIp), } } /// Retrieves an optional location to follow /// /// You need: /// 1. the option follow_location set to true /// 2. a 3xx response code /// 3. a header Location fn follow_location( &mut self, request_url: &Url, response: &Response, ) -> Result, HttpError> { let response_code = response.status; if !(300..400).contains(&response_code) { return Ok(None); } let Some(location) = response.headers.get(LOCATION) else { return Ok(None); }; let url = request_url.join(&location.value)?; Ok(Some(url)) } /// Returns cookie store. pub fn cookie_store(&mut self, logger: &mut Logger) -> CookieStore { let mut cookie_store = CookieStore::new(); let Ok(list) = self.handle.cookies() else { logger.warning("Cannot get cookies from libcurl"); return cookie_store; }; for cookie in list.iter() { let line = str::from_utf8(cookie).unwrap(); if cookie_store.add_cookie(line).is_err() { logger.warning(&format!("Line <{line}> can not be parsed as cookie")); } } cookie_store } /// Adds a cookie to the cookie jar. pub fn add_cookie(&mut self, cookie: &Cookie, logger: &mut Logger) { logger.debug(&format!("Add to cookie store <{cookie}> (experimental)")); self.handle.cookie_list(&cookie.to_netscape_str()).unwrap(); } /// Clears cookie storage. pub fn clear_cookie_storage(&mut self, logger: &mut Logger) { logger.debug("Clear cookie storage (experimental)"); self.handle.cookie_list("ALL").unwrap(); } /// Returns curl command-line for the HTTP `request_spec` run by this client. pub fn curl_command_line( &mut self, request_spec: &RequestSpec, context_dir: &ContextDir, output: Option<&Output>, options: &ClientOptions, logger: &mut Logger, ) -> CurlCmd { let cookies = self.cookie_store(logger); CurlCmd::new(request_spec, &cookies, context_dir, output, options) } /// Returns the SSL certificates information associated to this call. /// /// Certificate information are cached by libcurl handle connection id, in order to get /// SSL information even if libcurl connection is reused (see ). fn cert_info(&mut self, logger: &mut Logger) -> Result, HttpError> { if let Some(cert_info) = easy_ext::cert_info(&self.handle)? { match Certificate::try_from(cert_info) { Ok(value) => { // We try to get the connection id for the libcurl handle and cache the // certificate. Getting a connection id can fail on older libcurl version, we // don't cache the certificate in these cases. if let Ok(conn_id) = easy_ext::conn_id(&self.handle) { self.certificates.insert(conn_id, value.clone()); } Ok(Some(value)) } Err(message) => { logger.warning(&format!("Can not parse certificate - {message}")); Ok(None) } } } else { // We query the cache to see if we have a cached certificate for this connection; // As libcurl 8.2.0+ exposes the connection id through `CURLINFO_CONN_ID`, we don't // raise an error if we can't get a connection id (older version than 8.2.0), and return // a `None` certificate. match easy_ext::conn_id(&self.handle) { Ok(conn_id) => Ok(self.certificates.get(&conn_id).cloned()), Err(_) => Ok(None), } } } } /// Tests if credentials (`Authorization:`, `Cookie:` headers) should be filtered when there is a redirection /// from the first `original_url` to `redirect_url`. fn should_strip_credentials_on_redirect( original_url: &Url, redirect_url: &Url, follow_location_trusted: bool, ) -> bool { if follow_location_trusted { return false; } // Different origin != strip credentials if original_url.scheme() != redirect_url.scheme() { return true; } if original_url.host() != redirect_url.host() { return true; } // Treat different ports as different origins original_url.port() != redirect_url.port() } /// Returns the method used for redirecting a request/response with `response_status`. fn redirect_method(response_status: u32, original_method: &Method) -> Method { // This replicates curl's behavior match response_status { 301..=303 => Method("GET".to_string()), // Could be only 307 and 308, but curl does this for all 3xx // codes not converted to GET above. _ => original_method.clone(), } } impl Header { /// Parses an HTTP header line received from the server /// It does not panic. Just returns `None` if it can not be parsed. pub fn parse(line: &str) -> Option
{ match line.find(':') { Some(index) => { let (name, value) = line.split_at(index); Some(Header::new(name.trim(), value[1..].trim())) } None => None, } } } impl HeaderVec { /// Converts this list of [`Header`] to a lib curl header list. fn to_curl_headers(&self) -> Result { let mut curl_headers = List::new(); for header in self { if header.value.is_empty() { curl_headers.append(&format!("{};", header.name))?; } else { curl_headers.append(&format!("{}: {}", header.name, header.value))?; } } Ok(curl_headers) } } /// Splits an array of bytes into HTTP lines (\r\n separator). fn split_lines(data: &[u8]) -> Vec { let mut lines = vec![]; let mut start = 0; let mut i = 0; if data.is_empty() { return lines; } while i < (data.len() - 1) { if data[i] == 13 && data[i + 1] == 10 { if let Ok(s) = str::from_utf8(&data[start..i]) { lines.push(s.to_string()); } start = i + 2; i += 2; } else { i += 1; } } lines } /// Decodes optionally header value as text with UTF-8 or ISO-8859-1 encoding. fn decode_header(data: &[u8]) -> Option { match str::from_utf8(data) { Ok(s) => Some(s.to_string()), Err(_) => { // See the [WHATWG Encoding Standard](https://encoding.spec.whatwg.org/#note-latin1-ascii). // // > The windows-1252 encoding has various labels, such as "latin1", "iso-8859-1", and "ascii", // > which have historically been confusing for developers. On the web, and in any software // > that seeks to be web-compatible by implementing this standard, these are synonyms: "latin1" // > and "ascii" are just labels for windows-1252, and any software following this standard will, // > for example, decode 0x80 as U+20AC (€) when asked for the "Latin1" or "ASCII" decoding of that byte. // So: in the web platform world, ISO-8859-1 is just an alias for Windows-1252. // // In the [encoding_rs crate doc](https://docs.rs/encoding_rs/latest/encoding_rs/#iso-8859-1) // // > ISO-8859-1 does not exist as a distinct encoding from windows-1252 in the Encoding Standard. // > Therefore, an encoding that maps the unsigned byte value to the same Unicode scalar value is // > not available via Encoding in this crate. encoding_rs::WINDOWS_1252 .decode_without_bom_handling_and_without_replacement(data) .map(|s| s.to_string()) } } } /// Converts a list of [`String`] to a libcurl's list of strings. fn to_list(items: &[String]) -> Result { let mut list = List::new(); for item in items { list.append(item)?; } Ok(list) } /// Parses a cert file name, with a potential user provided password, and returns a pair of /// cert file name, password. /// See /// > In the portion of the argument, you must escape the character ":" as "\:" so /// > that it is not recognized as the password delimiter. Similarly, you must escape the character /// > "\" as "\\" so that it is not recognized as an escape character. fn parse_cert_password(cert_and_pass: &str) -> (String, Option) { let mut iter = cert_and_pass.chars(); let mut cert = String::new(); let mut password = String::new(); // The state of the parser: // - `true` if we're parsing the certificate portion of `cert_and_pass` // - `false` if we're parsing the password portion of `cert_and_pass` let mut parse_cert = true; while let Some(c) = iter.next() { if parse_cert { // We're parsing the certificate, do some escaping match c { '\\' => { // We read the next escaped char, if we failed, we're at the end of this string, // the read char is not an escaping \. match iter.next() { Some(c) => cert.push(c), None => { cert.push('\\'); break; } } } ':' if parse_cert => parse_cert = false, c => cert.push(c), } } else { // We have already found a cert/password separator, we don't need to escape anything now // we just update the password password.push(c); } } if parse_cert { (cert, None) } else { (cert, Some(password)) } } impl From for easy::HttpVersion { fn from(value: RequestedHttpVersion) -> Self { match value { RequestedHttpVersion::Default => easy::HttpVersion::Any, RequestedHttpVersion::Http10 => easy::HttpVersion::V10, RequestedHttpVersion::Http11 => easy::HttpVersion::V11, RequestedHttpVersion::Http2 => easy::HttpVersion::V2, RequestedHttpVersion::Http3 => easy::HttpVersion::V3, } } } impl From for easy::IpResolve { fn from(value: IpResolve) -> Self { match value { IpResolve::Default => easy::IpResolve::Any, IpResolve::IpV4 => easy::IpResolve::V4, IpResolve::IpV6 => easy::IpResolve::V6, } } } #[cfg(test)] mod tests { use std::default::Default; use std::path::PathBuf; use super::*; use crate::util::logger::LoggerOptionsBuilder; use crate::util::term::{Stderr, WriteMode}; #[test] fn test_parse_header() { assert_eq!( Header::parse("Foo: Bar\r\n").unwrap(), Header::new("Foo", "Bar") ); assert_eq!( Header::parse("Location: http://localhost:8000/redirected\r\n").unwrap(), Header::new("Location", "http://localhost:8000/redirected") ); assert!(Header::parse("Foo").is_none()); } #[test] fn test_split_lines_header() { let data = b"GET /hello HTTP/1.1\r\nHost: localhost:8000\r\n\r\n"; let lines = split_lines(data); assert_eq!(lines.len(), 3); assert_eq!(lines.first().unwrap().as_str(), "GET /hello HTTP/1.1"); assert_eq!(lines.get(1).unwrap().as_str(), "Host: localhost:8000"); assert_eq!(lines.get(2).unwrap().as_str(), ""); } #[test] fn test_redirect_method() { // Status of the response to be redirected | method of the original request | method of the new request let data = [ (301, "GET", "GET"), (301, "POST", "GET"), (301, "DELETE", "GET"), (302, "GET", "GET"), (302, "POST", "GET"), (302, "DELETE", "GET"), (303, "GET", "GET"), (303, "POST", "GET"), (303, "DELETE", "GET"), (304, "GET", "GET"), (304, "POST", "POST"), (304, "DELETE", "DELETE"), (308, "GET", "GET"), (308, "POST", "POST"), (308, "DELETE", "DELETE"), ]; for (status, original, redirected) in data { assert_eq!( redirect_method(status, &Method(original.to_string())), Method(redirected.to_string()) ); } } #[test] fn test_should_strip_credentials_on_redirect() { let url1 = Url::from_str("http://example.com").unwrap(); let url2 = Url::from_str("http://example.com:8080").unwrap(); let url3 = Url::from_str("https://example.com").unwrap(); let url4 = Url::from_str("http://other.com").unwrap(); let follow_location_trusted = false; assert!(should_strip_credentials_on_redirect( &url1, &url2, follow_location_trusted )); assert!(should_strip_credentials_on_redirect( &url1, &url3, follow_location_trusted )); assert!(should_strip_credentials_on_redirect( &url1, &url4, follow_location_trusted )); assert!(should_strip_credentials_on_redirect( &url1, &url3, follow_location_trusted )); let follow_location_trusted = true; assert!(!should_strip_credentials_on_redirect( &url1, &url2, follow_location_trusted )); assert!(!should_strip_credentials_on_redirect( &url1, &url3, follow_location_trusted )); assert!(!should_strip_credentials_on_redirect( &url1, &url4, follow_location_trusted )); assert!(!should_strip_credentials_on_redirect( &url1, &url3, follow_location_trusted )); } #[test] fn command_line_args() { let mut client = Client::new(); let request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("https://example.org").unwrap(), ..Default::default() }; let context_dir = ContextDir::default(); let file = Output::File(PathBuf::from("/tmp/foo.bin")); let output = Some(&file); let options = ClientOptions { aws_sigv4: Some("aws:amz:sts".to_string()), cacert_file: Some("/etc/cert.pem".to_string()), compressed: true, connects_to: vec!["example.com:443:host-47.example.com:443".to_string()], insecure: true, max_redirect: Count::Finite(10), path_as_is: true, proxy: Some("localhost:3128".to_string()), no_proxy: None, unix_socket: Some("/var/run/example.sock".to_string()), user: Some("user:password".to_string()), user_agent: Some("my-useragent".to_string()), verbosity: Some(Verbosity::VeryVerbose), ..Default::default() }; let logger_options = LoggerOptionsBuilder::default().build(); let stderr = Stderr::new(WriteMode::Immediate); let mut logger = Logger::new(&logger_options, stderr, &[]); let cmd = client.curl_command_line(&request, &context_dir, output, &options, &mut logger); assert_eq!( cmd.to_string(), "curl \ --aws-sigv4 aws:amz:sts \ --cacert /etc/cert.pem \ --compressed \ --connect-to example.com:443:host-47.example.com:443 \ --insecure \ --max-redirs 10 \ --path-as-is \ --proxy 'localhost:3128' \ --unix-socket '/var/run/example.sock' \ --user 'user:password' \ --user-agent 'my-useragent' \ --output /tmp/foo.bin \ 'https://example.org'" ); } #[test] fn parse_cert_option() { assert_eq!(parse_cert_password("foobar"), ("foobar".to_string(), None)); assert_eq!( parse_cert_password("foobar:toto"), ("foobar".to_string(), Some("toto".to_string())) ); assert_eq!( parse_cert_password("foobar:toto:tata"), ("foobar".to_string(), Some("toto:tata".to_string())) ); assert_eq!( parse_cert_password("foobar:"), ("foobar".to_string(), Some(String::new())) ); assert_eq!( parse_cert_password("foobar\\"), ("foobar\\".to_string(), None) ); assert_eq!( parse_cert_password("foo\\:bar\\:baz:toto:tata\\:tutu"), ( "foo:bar:baz".to_string(), Some("toto:tata\\:tutu".to_string()) ) ); assert_eq!( parse_cert_password("foo\\\\:toto\\:tata:tutu"), ("foo\\".to_string(), Some("toto\\:tata:tutu".to_string())) ); } #[test] fn test_to_curl_headers() { let mut headers = HeaderVec::new(); headers.push(Header::new("foo", "a")); headers.push(Header::new("bar", "b")); headers.push(Header::new("baz", "")); let list = headers.to_curl_headers().unwrap(); assert_eq!( list.iter().collect::>(), vec!["foo: a".as_bytes(), "bar: b".as_bytes(), "baz;".as_bytes()] ); } } hurl-7.1.0/src/http/cookie_store.rs000064400000000000000000000341401046102023000154060ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::http::Url; use core::fmt; use std::fmt::Formatter; /// Represents the storage of cookies for an HTTP client. #[derive(Default)] pub struct CookieStore { cookies: Vec, } impl CookieStore { /// Create a new instance. pub fn new() -> Self { CookieStore { cookies: vec![] } } /// Add a new cookie from a Netscape formatted string . pub fn add_cookie(&mut self, netscape_str: &str) -> Result<(), ParseCookieError> { let cookie = Cookie::from_netscape_str(netscape_str)?; self.cookies.push(cookie); Ok(()) } /// Returns an iterator over [`Cookie`]. pub fn cookies(&self) -> impl Iterator { self.cookies.iter() } /// Consumes the store and transform it into a vec of [`Cookie`] pub fn into_vec(self) -> Vec { self.cookies } } /// [Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) returned by /// the server with `Set-Cookie` header, and saved in the cookie storage of the internal HTTP /// engine. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Cookie { /// Defines the host to which the cookie will be sent. pub domain: String, pub include_subdomain: String, /// Indicates the path that must exist in the requested URL for the browser to send the Cookie header. pub path: String, /// Indicates that the cookie is sent to the server only when a request is made with the https: scheme pub https: String, /// Indicates the maximum lifetime of the cookie as an HTTP-date timestamp. pub expires: String, pub name: String, pub value: String, /// Forbids JavaScript from accessing the cookie. pub http_only: bool, } impl Cookie { /// Formats this cookie using Netscape cookie format. /// /// /// /// > The layout of Netscape's cookies.txt file is such that each line contains one name-value /// > pair. An example cookies.txt file may have an entry that looks like this: /// > /// > `.netscape.com TRUE / FALSE 946684799 NETSCAPE_ID 100103` /// > /// > Each line represents a single piece of stored information. A tab is inserted between each /// > of the fields. /// > From left-to-right, here is what each field represents: /// > - domain - The domain that created AND that can read the variable. /// > - flag - A TRUE/FALSE value indicating if all machines within a given domain can access /// > the variable. This value is set automatically by the browser, depending on the value you /// > set for domain. /// > - path - The path within the domain that the variable is valid for. /// > - secure - A TRUE/FALSE value indicating if a secure connection with the domain is /// > needed to access the variable. /// > - expiration - The UNIX time that the variable will expire on. UNIX time is defined as the /// > - number of seconds since Jan 1, 1970 00:00:00 GMT. /// > - name - The name of the variable. /// > - value - The value of the variable. pub fn to_netscape_str(&self) -> String { format!( "{}{}\t{}\t{}\t{}\t{}\t{}\t{}", if self.http_only { "#HttpOnly_" } else { "" }, self.domain, self.include_subdomain, self.path, self.https, self.expires, self.name, self.value ) } /// Creates a [`Cookie`] from a Netscape cookie formatted string. pub fn from_netscape_str(s: &str) -> Result { let mut tokens = CookieAttributes::new(s); let (http_only, domain) = if let Some(v) = tokens.next() { if let Some(domain) = v.strip_prefix("#HttpOnly_") { (true, domain.to_string()) } else { (false, v.to_string()) } } else { return Err(ParseCookieError); }; let include_subdomain = if let Some(v) = tokens.next() { v.to_string() } else { return Err(ParseCookieError); }; let path = if let Some(v) = tokens.next() { v.to_string() } else { return Err(ParseCookieError); }; let https = if let Some(v) = tokens.next() { v.to_string() } else { return Err(ParseCookieError); }; let expires = if let Some(v) = tokens.next() { v.to_string() } else { return Err(ParseCookieError); }; let name = if let Some(v) = tokens.next() { v.to_string() } else { return Err(ParseCookieError); }; let value = if let Some(v) = tokens.next() { v.to_string() } else { String::new() }; Ok(Cookie { domain, include_subdomain, path, https, expires, name, value, http_only, }) } pub fn is_expired(&self) -> bool { // cookie expired when libcurl set value to 1? self.expires == "1" } pub fn include_subdomain(&self) -> bool { self.include_subdomain == "TRUE" } pub fn match_domain(&self, url: &Url) -> bool { // We remove the legacy optional dot in cookie domain. let cookie_domain = self.domain.strip_prefix(".").unwrap_or(&self.domain); if let Some(url_domain) = url.domain() { if !self.include_subdomain() { if url_domain != cookie_domain { return false; } } else if !url_domain.ends_with(&cookie_domain) { return false; } } url.path().starts_with(&self.path) } } impl fmt::Display for Cookie { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { let repr = self.to_netscape_str(); write!(f, "{repr}") } } /// Represents an iterator over cookie attributes parsed from a Netscape formatted string /// (see ). /// The Netscape format uses tab as separator, we want also to import cookie with a space /// separator (for inline use in Hurl files with `@cookie_storage` command for instance). /// The format has only 7 values, and the last token can include whitespaces. struct CookieAttributes<'line> { line: &'line str, /// Current index of the char pos: BytePos, /// Number of values parsed parts: usize, } #[derive(Copy, Clone)] struct BytePos(usize); impl<'line> CookieAttributes<'line> { fn new(line: &'line str) -> Self { CookieAttributes { line, pos: BytePos(0), parts: 0, } } #[inline] fn skip_whitespace(&mut self) { let bytes = self.line.as_bytes(); while self.pos.0 < bytes.len() && is_whitespace(bytes[self.pos.0]) { self.pos.0 += 1; } } } #[inline] fn is_whitespace(b: u8) -> bool { matches!(b, b' ' | b'\t' | b'\r' | b'\n') } impl<'line> Iterator for CookieAttributes<'line> { type Item = &'line str; fn next(&mut self) -> Option { if self.parts == 7 { return None; } // Skip leading whitespace self.skip_whitespace(); if self.pos.0 >= self.line.len() { return None; } // 7th logical field = remainder (value may contain spaces) if self.parts == 6 { self.parts += 1; return Some(&self.line[self.pos.0..]); } let start = self.pos; let bytes = self.line.as_bytes(); while self.pos.0 < bytes.len() && !is_whitespace(bytes[self.pos.0]) { self.pos.0 += 1; } let end = self.pos; self.parts += 1; Some(&self.line[start.0..end.0]) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ParseCookieError; #[cfg(test)] mod tests { use super::*; use std::str::FromStr; #[test] pub fn parse_cookie_from_str() { assert_eq!( Cookie::from_netscape_str("httpbin.org\tFALSE\t/\tFALSE\t0\tcookie1\tvalueA").unwrap(), Cookie { domain: "httpbin.org".to_string(), include_subdomain: "FALSE".to_string(), path: "/".to_string(), https: "FALSE".to_string(), expires: "0".to_string(), name: "cookie1".to_string(), value: "valueA".to_string(), http_only: false, } ); assert_eq!( Cookie::from_netscape_str("localhost\tFALSE\t/\tFALSE\t1\tcookie2\t").unwrap(), Cookie { domain: "localhost".to_string(), include_subdomain: "FALSE".to_string(), path: "/".to_string(), https: "FALSE".to_string(), expires: "1".to_string(), name: "cookie2".to_string(), value: String::new(), http_only: false, } ); assert_eq!( Cookie::from_netscape_str("localhost FALSE / FALSE 1 cookie3 value3").unwrap(), Cookie { domain: "localhost".to_string(), include_subdomain: "FALSE".to_string(), path: "/".to_string(), https: "FALSE".to_string(), expires: "1".to_string(), name: "cookie3".to_string(), value: "value3".to_string(), http_only: false, } ); assert_eq!( Cookie::from_netscape_str("#HttpOnly_localhost FALSE / FALSE 1 cookie3 a b c").unwrap(), Cookie { domain: "localhost".to_string(), include_subdomain: "FALSE".to_string(), path: "/".to_string(), https: "FALSE".to_string(), expires: "1".to_string(), name: "cookie3".to_string(), value: "a b c".to_string(), http_only: true, } ); assert_eq!( Cookie::from_netscape_str("xxx").err().unwrap(), ParseCookieError ); } #[test] fn test_match_cookie() { let cookie = Cookie { domain: "example.com".to_string(), include_subdomain: "FALSE".to_string(), path: "/".to_string(), https: String::new(), expires: String::new(), name: String::new(), value: String::new(), http_only: false, }; assert!(cookie.match_domain(&Url::from_str("http://example.com/toto").unwrap())); assert!(!cookie.match_domain(&Url::from_str("http://sub.example.com/tata").unwrap())); assert!(!cookie.match_domain(&Url::from_str("http://toto/tata").unwrap())); let cookie = Cookie { domain: "example.com".to_string(), include_subdomain: "TRUE".to_string(), path: "/toto".to_string(), https: String::new(), expires: String::new(), name: String::new(), value: String::new(), http_only: false, }; assert!(cookie.match_domain(&Url::from_str("http://example.com/toto").unwrap())); assert!(cookie.match_domain(&Url::from_str("http://sub.example.com/toto").unwrap())); assert!(!cookie.match_domain(&Url::from_str("http://example.com/tata").unwrap())); // Legacy cookie domain with dot prefix let cookie = Cookie { domain: ".example.com".to_string(), include_subdomain: "TRUE".to_string(), path: "/foo".to_string(), https: String::new(), expires: String::new(), name: String::new(), value: String::new(), http_only: false, }; assert!(cookie.match_domain(&Url::from_str("http://example.com/foo").unwrap())); assert!(cookie.match_domain(&Url::from_str("http://sub.example.com/foo").unwrap())); assert!(!cookie.match_domain(&Url::from_str("http://example.com/tata").unwrap())); assert!(!cookie.match_domain(&Url::from_str("http://sub.example.com/tata").unwrap())); } #[test] fn test_add_cookie() { let mut cookie_store = CookieStore::new(); cookie_store .add_cookie("localhost TRUE / FALSE 0 cookie1 valueA") .unwrap(); cookie_store .add_cookie( "#HttpOnly_example.com\t\t FALSE\t\t \t/\tFALSE\t1\tcookie2\tfoo bar baz", ) .unwrap(); let cookies = cookie_store.into_vec(); assert_eq!(cookies.len(), 2); assert_eq!( cookies[0], Cookie { domain: "localhost".to_string(), include_subdomain: "TRUE".to_string(), path: "/".to_string(), https: "FALSE".to_string(), expires: "0".to_string(), name: "cookie1".to_string(), value: "valueA".to_string(), http_only: false, } ); assert_eq!( cookies[1], Cookie { domain: "example.com".to_string(), include_subdomain: "FALSE".to_string(), path: "/".to_string(), https: "FALSE".to_string(), expires: "1".to_string(), name: "cookie2".to_string(), value: "foo bar baz".to_string(), http_only: true, } ); } } hurl-7.1.0/src/http/curl_cmd.rs000064400000000000000000001056011046102023000145120ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use core::fmt; use std::collections::HashMap; use std::path::Path; use hurl_core::types::Count; use crate::runner::Output; use crate::util::path::ContextDir; use super::cookie_store::CookieStore; use super::header::{Header, HeaderVec, CONTENT_TYPE}; use super::options::ClientOptions; use super::param::Param; use super::request::{IpResolve, RequestedHttpVersion}; use super::request_spec::{Body, FileParam, Method, MultipartParam, RequestSpec}; /// Represents a curl command, with arguments. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CurlCmd { /// The args of this command. args: Vec, } impl fmt::Display for CurlCmd { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.args.join(" ")) } } impl Default for CurlCmd { fn default() -> Self { CurlCmd { args: vec!["curl".to_string()], } } } impl CurlCmd { /// Creates a new curl command, based on an HTTP request, cookies, a context directory, output /// and runner options. pub fn new( request_spec: &RequestSpec, cookie_store: &CookieStore, context_dir: &ContextDir, output: Option<&Output>, options: &ClientOptions, ) -> Self { let mut args = vec!["curl".to_string()]; let mut params = method_params(request_spec, options.follow_location); args.append(&mut params); let options_headers = options .headers .iter() .map(|h| h.as_str()) .collect::>(); let headers = &request_spec.headers.with_raw_headers(&options_headers); let mut params = headers_params( headers, request_spec.implicit_content_type.as_deref(), &request_spec.body, ); args.append(&mut params); let mut params = body_params(request_spec, context_dir); args.append(&mut params); let mut params = cookies_params(request_spec, cookie_store); args.append(&mut params); let mut params = other_options_params(context_dir, output, options); args.append(&mut params); let mut params = url_param(request_spec); args.append(&mut params); CurlCmd { args } } } /// Returns the curl args corresponding to the HTTP method, from a request spec. fn method_params(request_spec: &RequestSpec, follow_location: bool) -> Vec { let has_body = !request_spec.multipart.is_empty() || !request_spec.form.is_empty() || !request_spec.body.bytes().is_empty(); request_spec.method.curl_args(has_body, follow_location) } /// Returns the curl args corresponding to the HTTP headers, from a list of headers, /// an optional implicit content type, and the request body. fn headers_params( headers: &HeaderVec, implicit_content_type: Option<&str>, body: &Body, ) -> Vec { let mut args = vec![]; for header in headers.iter() { args.append(&mut header.curl_args()); } let has_explicit_content_type = headers.contains_key(CONTENT_TYPE); if has_explicit_content_type { return args; } if let Some(content_type) = implicit_content_type { if content_type != "application/x-www-form-urlencoded" && content_type != "multipart/form-data" { args.push("--header".to_string()); args.push(format!("'{CONTENT_TYPE}: {content_type}'")); } } else if !body.bytes().is_empty() { match body { Body::Text(_) => { args.push("--header".to_string()); args.push(format!("'{CONTENT_TYPE}:'")); } Body::Binary(_) => { args.push("--header".to_string()); args.push(format!("'{CONTENT_TYPE}: application/octet-stream'")); } Body::File(_, _) => { args.push("--header".to_string()); args.push(format!("'{CONTENT_TYPE}:'")); } } } args } /// Returns the curl args corresponding to the request body, from a request spec. fn body_params(request_spec: &RequestSpec, context_dir: &ContextDir) -> Vec { let mut args = vec![]; for param in request_spec.form.iter() { args.push("--data".to_string()); args.push(format!("'{}'", param.curl_arg_escape())); } for param in request_spec.multipart.iter() { args.push("--form".to_string()); args.push(format!("'{}'", param.curl_arg(context_dir))); } if request_spec.body.bytes().is_empty() { return args; } // See and : // // > -d, --data // > ... // > If you start the data with the letter @, the rest should be a file name to read the // > data from, or - if you want curl to read the data from stdin. Posting data from a // > file named 'foobar' would thus be done with -d, --data @foobar. When -d, --data is // > told to read from a file like that, carriage returns and newlines will be stripped // > out. If you do not want the @ character to have a special interpretation use // > --data-raw instead. // > ... // > --data-binary // > // > (HTTP) This posts data exactly as specified with no extra processing whatsoever. // // In summary: if the payload is a file (@foo.bin), we must use --data-binary option in // order to curl to not process the data sent. let param = match request_spec.body { Body::File(_, _) => "--data-binary", _ => "--data", }; args.push(param.to_string()); args.push(request_spec.body.curl_arg(context_dir)); args } /// Returns the curl args corresponding to a list of cookies. fn cookies_params(request_spec: &RequestSpec, cookie_store: &CookieStore) -> Vec { // Constructs cookies values from current request let mut cookies_from_req = request_spec .cookies .iter() .map(|c| (&c.name, &c.value)) .collect::>(); // Constructs cookies values from cookie store for this URL, that are not expired let mut cookies_from_store = cookie_store .cookies() .filter(|c| !c.is_expired()) .filter(|c| c.match_domain(&request_spec.url)) .map(|c| (&c.name, &c.value)) .collect::>(); let mut all_cookies = vec![]; all_cookies.append(&mut cookies_from_req); all_cookies.append(&mut cookies_from_store); if all_cookies.is_empty() { return vec![]; } let mut args = vec![]; args.push("--cookie".to_string()); let value = all_cookies .iter() .map(|(name, value)| format!("{name}={value}")) .collect::>() .join("; "); args.push(format!("'{value}'")); args } /// Returns the curl args corresponding to run options. fn other_options_params( context_dir: &ContextDir, output: Option<&Output>, options: &ClientOptions, ) -> Vec { let mut args = options.curl_args(); // --output is not an option of the HTTP client, we deal with it here: match output { Some(Output::File(filename)) => { let filename = context_dir.resolved_path(filename); args.push("--output".to_string()); args.push(filename.to_string_lossy().to_string()); } Some(Output::Stdout) => { args.push("--output".to_string()); args.push("-".to_string()); } None => {} } args } /// Returns the curl args corresponding to the URL, from a request spec. fn url_param(request_spec: &RequestSpec) -> Vec { let mut args = vec![]; let querystring = if request_spec.querystring.is_empty() { String::new() } else { let params = request_spec .querystring .iter() .map(|p| p.curl_arg_escape()) .collect::>(); params.join("&") }; let url = if querystring.as_str() == "" { request_spec.url.raw() } else if request_spec.url.raw().contains('?') { format!("{}&{}", request_spec.url.raw(), querystring) } else { format!("{}?{}", request_spec.url.raw(), querystring) }; let url = format!("'{url}'"); // curl support "globbing" // {,},[,] have special meaning to curl, in order to support templating. // We have two options: // - either we encode {,},[,] to %7b,%7d,%5b,%%5d // - or we let the url "as-it" and use curl [`--globoff`](https://curl.se/docs/manpage.html#-g) option. // We're going with the second one! if url.contains('{') || url.contains('}') || url.contains('[') || url.contains(']') { args.push("--globoff".to_string()); } args.push(url); args } fn encode_byte(b: u8) -> String { format!("\\x{b:02x}") } /// Encode bytes to a shell string. fn encode_bytes(bytes: &[u8]) -> String { bytes.iter().map(|b| encode_byte(*b)).collect() } impl Method { /// Returns the curl args for HTTP method, given the request has a body or not. fn curl_args(&self, has_body: bool, follow_location: bool) -> Vec { match self.0.as_str() { "GET" => { if has_body { vec!["--request".to_string(), "GET".to_string()] } else { vec![] } } "HEAD" => vec!["--head".to_string()], "POST" => { // In , `--location` and `--request/-X` does not well play together: // // > The method string you set with -X, --request is used for all requests, which if you for example use -L, // > --location may cause unintended side-effects when curl does not change request method according to the // > HTTP 30x response codes - and similar. // // When we use `--request POST` with curl, we're telling curl to make POST requests for every request. This // can interfere with `--location` option that can make GET requests following the initial POST. So, in the // case of a POST request with `--location` option, we don't force the method to let curl decides the right // method of the redirection steps. if has_body { vec![] } else if follow_location { vec!["--data".to_string(), "''".to_string()] } else { vec!["--request".to_string(), "POST".to_string()] } } s => vec!["--request".to_string(), s.to_string()], } } } impl Header { fn curl_args(&self) -> Vec { let name = &self.name; let value = &self.value; vec![ "--header".to_string(), if self.value.is_empty() { encode_shell_string(&format!("{name};")) } else { encode_shell_string(&format!("{name}: {value}")) }, ] } } impl Param { fn curl_arg_escape(&self) -> String { let name = &self.name; let value = escape_url(&self.value); format!("{name}={value}") } fn curl_arg(&self) -> String { let name = &self.name; let value = &self.value; format!("{name}={value}") } } impl MultipartParam { fn curl_arg(&self, context_dir: &ContextDir) -> String { match self { MultipartParam::Param(param) => param.curl_arg(), MultipartParam::FileParam(FileParam { name, filename, content_type, .. }) => { let path = context_dir.resolved_path(Path::new(filename)); let value = format!("@{};type={}", path.to_string_lossy(), content_type); format!("{name}={value}") } } } } impl Body { fn curl_arg(&self, context_dir: &ContextDir) -> String { match self { Body::Text(s) => encode_shell_string(s), Body::Binary(bytes) => format!("$'{}'", encode_bytes(bytes)), Body::File(_, filename) => { let path = context_dir.resolved_path(Path::new(filename)); format!("'@{}'", path.to_string_lossy()) } } } } impl ClientOptions { /// Returns the list of options for the curl command line equivalent to this [`ClientOptions`]. fn curl_args(&self) -> Vec { let mut arguments = vec![]; if let Some(ref aws_sigv4) = self.aws_sigv4 { arguments.push("--aws-sigv4".to_string()); arguments.push(aws_sigv4.clone()); } if let Some(ref cacert_file) = self.cacert_file { arguments.push("--cacert".to_string()); arguments.push(cacert_file.clone()); } if let Some(ref client_cert_file) = self.client_cert_file { arguments.push("--cert".to_string()); arguments.push(client_cert_file.clone()); } if let Some(ref client_key_file) = self.client_key_file { arguments.push("--key".to_string()); arguments.push(client_key_file.clone()); } if self.compressed { arguments.push("--compressed".to_string()); } if self.connect_timeout != ClientOptions::default().connect_timeout { arguments.push("--connect-timeout".to_string()); arguments.push(self.connect_timeout.as_secs().to_string()); } for connect in self.connects_to.iter() { arguments.push("--connect-to".to_string()); arguments.push(connect.clone()); } if let Some(ref cookie_file) = self.cookie_input_file { arguments.push("--cookie".to_string()); arguments.push(cookie_file.clone()); } match self.http_version { RequestedHttpVersion::Default => {} RequestedHttpVersion::Http10 => arguments.push("--http1.0".to_string()), RequestedHttpVersion::Http11 => arguments.push("--http1.1".to_string()), RequestedHttpVersion::Http2 => arguments.push("--http2".to_string()), RequestedHttpVersion::Http3 => arguments.push("--http3".to_string()), } if self.insecure { arguments.push("--insecure".to_string()); } match self.ip_resolve { IpResolve::Default => {} IpResolve::IpV4 => arguments.push("--ipv4".to_string()), IpResolve::IpV6 => arguments.push("--ipv6".to_string()), } if self.follow_location_trusted { arguments.push("--location-trusted".to_string()); } else if self.follow_location { arguments.push("--location".to_string()); } if let Some(max_filesize) = self.max_filesize { arguments.push("--max-filesize".to_string()); arguments.push(max_filesize.to_string()); } if let Some(max_speed) = self.max_recv_speed { arguments.push("--limit-rate".to_string()); arguments.push(max_speed.to_string()); } // We don't implement --limit-rate for self.max_send_speed as curl limit-rate seems // to limit both upload and download speed. There is no distinct option.. if self.max_redirect != ClientOptions::default().max_redirect { let max_redirect = match self.max_redirect { Count::Finite(n) => n as i32, Count::Infinite => -1, }; arguments.push("--max-redirs".to_string()); arguments.push(max_redirect.to_string()); } if self.timeout != ClientOptions::default().timeout { arguments.push("--max-time".to_string()); arguments.push(self.timeout.as_secs().to_string()); } if self.negotiate { arguments.push("--negotiate".to_string()); } if let Some(filename) = &self.netrc_file { arguments.push("--netrc-file".to_string()); arguments.push(format!("'{filename}'")); } if self.netrc_optional { arguments.push("--netrc-optional".to_string()); } if self.netrc { arguments.push("--netrc".to_string()); } if self.ntlm { arguments.push("--ntlm".to_string()); } if self.path_as_is { arguments.push("--path-as-is".to_string()); } if let Some(ref pinned_pub_key) = self.pinned_pub_key { arguments.push("--pinnedpubkey".to_string()); arguments.push(pinned_pub_key.clone()); } if let Some(ref proxy) = self.proxy { arguments.push("--proxy".to_string()); arguments.push(format!("'{proxy}'")); } for resolve in self.resolves.iter() { arguments.push("--resolve".to_string()); arguments.push(resolve.clone()); } if self.ssl_no_revoke { arguments.push("--ssl-no-revoke".to_string()); } if let Some(ref unix_socket) = self.unix_socket { arguments.push("--unix-socket".to_string()); arguments.push(format!("'{unix_socket}'")); } if let Some(ref user) = self.user { arguments.push("--user".to_string()); arguments.push(format!("'{user}'")); } if let Some(ref user_agent) = self.user_agent { arguments.push("--user-agent".to_string()); arguments.push(format!("'{user_agent}'")); } arguments } } fn escape_url(s: &str) -> String { percent_encoding::percent_encode(s.as_bytes(), percent_encoding::NON_ALPHANUMERIC).to_string() } fn encode_shell_string(s: &str) -> String { // $'...' form will be used to encode escaped sequence if escape_mode(s) { let escaped = escape_string(s); format!("$'{escaped}'") } else { format!("'{s}'") } } // the shell string must be in escaped mode ($'...') // if it contains \n, \t or ' fn escape_mode(s: &str) -> bool { for c in s.chars() { if c == '\n' || c == '\t' || c == '\'' { return true; } } false } fn escape_string(s: &str) -> String { let mut escaped_sequences = HashMap::new(); escaped_sequences.insert('\n', "\\n"); escaped_sequences.insert('\t', "\\t"); escaped_sequences.insert('\'', "\\'"); escaped_sequences.insert('\\', "\\\\"); let mut escaped = String::new(); for c in s.chars() { match escaped_sequences.get(&c) { None => escaped.push(c), Some(escaped_seq) => escaped.push_str(escaped_seq), } } escaped } #[cfg(test)] mod tests { use std::path::Path; use std::str::FromStr; use std::time::Duration; use hurl_core::types::BytesPerSec; use super::*; use crate::http::{HeaderVec, Url}; #[test] fn hello_request_with_default_options() { let mut request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost:8000/hello").unwrap(), ..Default::default() }; let context_dir = ContextDir::default(); let cookie_store = CookieStore::new(); let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!(cmd.to_string(), "curl 'http://localhost:8000/hello'"); // Same requests with some output: let output = Some(Output::new("foo.out")); let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl \ --output foo.out \ 'http://localhost:8000/hello'" ); // With some headers let mut headers = HeaderVec::new(); headers.push(Header::new("User-Agent", "iPhone")); headers.push(Header::new("Foo", "Bar")); request.headers = headers; let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl \ --header 'User-Agent: iPhone' \ --header 'Foo: Bar' \ --output foo.out \ 'http://localhost:8000/hello'" ); // With some cookies: let mut cookie_store = CookieStore::new(); cookie_store .add_cookie("localhost TRUE / FALSE 0 cookie1 valueA") .unwrap(); cookie_store .add_cookie("localhost FALSE / FALSE 1 cookie2 valueB") .unwrap(); let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl \ --header 'User-Agent: iPhone' \ --header 'Foo: Bar' \ --cookie 'cookie1=valueA' \ --output foo.out \ 'http://localhost:8000/hello'" ); } #[test] fn hello_request_with_options() { let request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost:8000/hello").unwrap(), ..Default::default() }; let context_dir = ContextDir::default(); let cookie_store = CookieStore::new(); let options = ClientOptions { allow_reuse: true, aws_sigv4: None, cacert_file: None, client_cert_file: None, client_key_file: None, compressed: true, connect_timeout: Duration::from_secs(20), connects_to: vec!["example.com:443:host-47.example.com:443".to_string()], cookie_input_file: Some("cookie_file".to_string()), follow_location: true, follow_location_trusted: false, headers: vec![ "Test-Header-1: content-1".to_string(), "Test-Header-2: content-2".to_string(), "Test-Header-Empty:".to_string(), ], http_version: RequestedHttpVersion::Http10, insecure: true, ip_resolve: IpResolve::IpV6, max_filesize: None, max_recv_speed: Some(BytesPerSec(8000)), max_redirect: Count::Finite(10), max_send_speed: Some(BytesPerSec(8000)), negotiate: true, netrc: false, netrc_file: Some("/var/run/netrc".to_string()), netrc_optional: true, ntlm: true, path_as_is: true, pinned_pub_key: None, proxy: Some("localhost:3128".to_string()), no_proxy: None, resolves: vec![ "foo.com:80:192.168.0.1".to_string(), "bar.com:443:127.0.0.1".to_string(), ], ssl_no_revoke: false, timeout: Duration::from_secs(10), unix_socket: Some("/var/run/example.sock".to_string()), user: Some("user:password".to_string()), user_agent: Some("my-useragent".to_string()), verbosity: None, }; let cmd = CurlCmd::new(&request, &cookie_store, &context_dir, None, &options); assert_eq!( cmd.to_string(), "curl \ --header 'Test-Header-1: content-1' \ --header 'Test-Header-2: content-2' \ --header 'Test-Header-Empty;' \ --compressed \ --connect-timeout 20 \ --connect-to example.com:443:host-47.example.com:443 \ --cookie cookie_file \ --http1.0 \ --insecure \ --ipv6 \ --location \ --limit-rate 8000 \ --max-redirs 10 \ --max-time 10 \ --negotiate \ --netrc-file '/var/run/netrc' \ --netrc-optional \ --ntlm \ --path-as-is \ --proxy 'localhost:3128' \ --resolve foo.com:80:192.168.0.1 \ --resolve bar.com:443:127.0.0.1 \ --unix-socket '/var/run/example.sock' \ --user 'user:password' \ --user-agent 'my-useragent' \ 'http://localhost:8000/hello'" ); } #[test] fn url_with_dot() { let request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("https://example.org/hello/../to/../your/../file").unwrap(), ..Default::default() }; let context_dir = ContextDir::default(); let cookies = CookieStore::new(); let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new(&request, &cookies, &context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl 'https://example.org/hello/../to/../your/../file'" ); } #[test] fn url_with_curl_glob() { let request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://foo.com?param1=value1¶m2={bar}").unwrap(), ..Default::default() }; let context_dir = ContextDir::default(); let cookie_store = CookieStore::new(); let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl \ --globoff \ 'http://foo.com?param1=value1¶m2={bar}'" ); } #[test] fn query_request() { let mut request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost:8000/querystring-params").unwrap(), querystring: vec![ Param { name: String::from("param1"), value: String::from("value1"), }, Param { name: String::from("param2"), value: String::from("a b"), }, ], ..Default::default() }; let context_dir = ContextDir::default(); let cookie_store = CookieStore::new(); let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl 'http://localhost:8000/querystring-params?param1=value1¶m2=a%20b'", ); // Add som query param in the URL request.url = Url::from_str("http://localhost:8000/querystring-params?param3=foo¶m4=bar") .unwrap(); let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl 'http://localhost:8000/querystring-params?param3=foo¶m4=bar¶m1=value1¶m2=a%20b'", ); } #[test] fn form_request() { let mut headers = HeaderVec::new(); headers.push(Header::new( "Content-Type", "application/x-www-form-urlencoded", )); let request = RequestSpec { method: Method("POST".to_string()), url: Url::from_str("http://localhost/form-params").unwrap(), headers, form: vec![Param::new("param1", "value1"), Param::new("param2", "a b")], implicit_content_type: Some("multipart/form-data".to_string()), ..Default::default() }; let context_dir = ContextDir::default(); let cookie_store = CookieStore::new(); let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data 'param1=value1' \ --data 'param2=a%20b' \ 'http://localhost/form-params'" ); } #[test] fn json_request() { let mut headers = HeaderVec::new(); headers.push(Header::new("content-type", "application/vnd.api+json")); let mut request = RequestSpec { method: Method("POST".to_string()), url: Url::from_str("http://localhost/json").unwrap(), headers, body: Body::Text(String::new()), implicit_content_type: Some("application/json".to_string()), ..Default::default() }; let context_dir = ContextDir::default(); let cookie_store = CookieStore::new(); let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl \ --request POST \ --header 'content-type: application/vnd.api+json' \ 'http://localhost/json'" ); // Add a non-empty body request.body = Body::Text("{\"foo\":\"bar\"}".to_string()); let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl \ --header 'content-type: application/vnd.api+json' \ --data '{\"foo\":\"bar\"}' \ 'http://localhost/json'" ); // Change method request.method = Method("PUT".to_string()); let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl \ --request PUT \ --header 'content-type: application/vnd.api+json' \ --data '{\"foo\":\"bar\"}' \ 'http://localhost/json'" ); } #[test] fn post_binary_file() { let request = RequestSpec { method: Method("POST".to_string()), url: Url::from_str("http://localhost:8000/hello").unwrap(), body: Body::File(b"Hello World!".to_vec(), "foo.bin".to_string()), ..Default::default() }; let context_dir = ContextDir::default(); let cookie_store = CookieStore::new(); let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new( &request, &cookie_store, &context_dir, output.as_ref(), &options, ); assert_eq!( cmd.to_string(), "curl \ --header 'Content-Type:' \ --data-binary '@foo.bin' \ 'http://localhost:8000/hello'" ); } #[test] fn test_encode_byte() { assert_eq!(encode_byte(1), "\\x01".to_string()); assert_eq!(encode_byte(32), "\\x20".to_string()); } #[test] fn header_curl_args() { assert_eq!( Header::new("Host", "example.com").curl_args(), vec!["--header".to_string(), "'Host: example.com'".to_string()] ); assert_eq!( Header::new("If-Match", "\"e0023aa4e\"").curl_args(), vec![ "--header".to_string(), "'If-Match: \"e0023aa4e\"'".to_string() ] ); } #[test] fn param_curl_args() { assert_eq!( Param { name: "param1".to_string(), value: "value1".to_string(), } .curl_arg(), "param1=value1".to_string() ); assert_eq!( Param { name: "param2".to_string(), value: String::new(), } .curl_arg(), "param2=".to_string() ); assert_eq!( Param { name: "param3".to_string(), value: "a=b".to_string(), } .curl_arg_escape(), "param3=a%3Db".to_string() ); assert_eq!( Param { name: "param4".to_string(), value: "1,2,3".to_string(), } .curl_arg_escape(), "param4=1%2C2%2C3".to_string() ); } #[test] fn test_encode_body() { let current_dir = Path::new("/tmp"); let file_root = Path::new("/tmp"); let context_dir = ContextDir::new(current_dir, file_root); assert_eq!( Body::Text("hello".to_string()).curl_arg(&context_dir), "'hello'".to_string() ); if cfg!(unix) { assert_eq!( Body::File(vec![], "filename".to_string()).curl_arg(&context_dir), "'@/tmp/filename'".to_string() ); } assert_eq!( Body::Binary(vec![1, 2, 3]).curl_arg(&context_dir), "$'\\x01\\x02\\x03'".to_string() ); } #[test] fn test_encode_shell_string() { assert_eq!(encode_shell_string("hello"), "'hello'"); assert_eq!(encode_shell_string("\\n"), "'\\n'"); assert_eq!(encode_shell_string("'"), "$'\\''"); assert_eq!(encode_shell_string("\\'"), "$'\\\\\\''"); assert_eq!(encode_shell_string("\n"), "$'\\n'"); } #[test] fn test_escape_string() { assert_eq!(escape_string("hello"), "hello"); assert_eq!(escape_string("\\n"), "\\\\n"); assert_eq!(escape_string("'"), "\\'"); assert_eq!(escape_string("\\'"), "\\\\\\'"); assert_eq!(escape_string("\n"), "\\n"); } #[test] fn test_escape_mode() { assert!(!escape_mode("hello")); assert!(!escape_mode("\\")); assert!(escape_mode("'")); assert!(escape_mode("\n")); } } hurl-7.1.0/src/http/debug.rs000064400000000000000000000054361046102023000140150ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::runner::hex; use crate::util::logger::Logger; use super::header::HeaderVec; use super::mimetype; /// Logs a buffer of bytes representing an HTTP request or response `body`. /// If the body is kind of text, we log all the text lines. If we can't detect that this is a text /// body (using Content-Type header in `headers`), we print the first 64 bytes. /// TODO: this function does not manage any kind of compression so we can only use for an HTTP /// request. For an HTTP response, see `[crate::http::Response::log_body]`. /// If `debug` is true, logs are printed using debug (with * prefix), otherwise logs are printed /// in info. pub fn log_body(body: &[u8], headers: &HeaderVec, debug: bool, logger: &mut Logger) { if let Some(content_type) = headers.content_type() { if !mimetype::is_kind_of_text(content_type) { log_bytes(body, 64, debug, logger); return; } } // Decode body as text: let encoding = match headers.character_encoding() { Ok(encoding) => encoding, Err(_) => { log_bytes(body, 64, debug, logger); return; } }; match encoding.decode_without_bom_handling_and_without_replacement(body) { Some(text) => log_text(&text, debug, logger), None => log_bytes(body, 64, debug, logger), } } /// Debug log text. pub fn log_text(text: &str, debug: bool, logger: &mut Logger) { if text.is_empty() { if debug { logger.debug(""); } else { logger.info(""); } } else { let lines = text.split('\n'); if debug { lines.for_each(|l| logger.debug(l)); } else { lines.for_each(|l| logger.info(l)); } } } /// Debug log `bytes` with a maximum size of `max` bytes. pub fn log_bytes(bytes: &[u8], max: usize, debug: bool, logger: &mut Logger) { let bytes = if bytes.len() > max { &bytes[..max] } else { bytes }; let log = if bytes.is_empty() { String::new() } else { format!("Bytes <{}...>", hex::encode(bytes)) }; if debug { logger.debug(&log); } else { logger.info(&log); } } hurl-7.1.0/src/http/easy_ext.rs000064400000000000000000000327471046102023000145550ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::ffi::{CStr, CString}; use std::ptr; use std::time::Duration; use curl::easy::Easy; use curl::Error; use curl_sys::{curl_certinfo, curl_off_t, curl_slist, CURLINFO, CURLOPT_NETRC_FILE}; /// Some definitions not present in curl-sys const CURLINFO_OFF_T: CURLINFO = 0x600000; const CURLINFO_TOTAL_TIME_T: CURLINFO = CURLINFO_OFF_T + 50; const CURLINFO_NAMELOOKUP_TIME_T: CURLINFO = CURLINFO_OFF_T + 51; const CURLINFO_CONNECT_TIME_T: CURLINFO = CURLINFO_OFF_T + 52; const CURLINFO_PRETRANSFER_TIME_T: CURLINFO = CURLINFO_OFF_T + 53; const CURLINFO_STARTTRANSFER_TIME_T: CURLINFO = CURLINFO_OFF_T + 54; const CURLINFO_APPCONNECT_TIME_T: CURLINFO = CURLINFO_OFF_T + 56; const CURLINFO_CONN_ID: CURLINFO = CURLINFO_OFF_T + 64; /// Represents certificate information. /// `data` has format "name:content"; #[derive(Clone)] pub struct CertInfo { pub data: Vec, } /// Returns the information of the first certificate in the certificates chain. pub fn cert_info(easy: &Easy) -> Result, Error> { unsafe { let mut certinfo = ptr::null_mut::(); let rc = curl_sys::curl_easy_getinfo(easy.raw(), curl_sys::CURLINFO_CERTINFO, &mut certinfo); cvt(easy, rc)?; if certinfo.is_null() { return Ok(None); } let count = (*certinfo).num_of_certs; if count <= 0 { return Ok(None); } let slist = *((*certinfo).certinfo.offset(0)); let data = to_list(slist); Ok(Some(CertInfo { data })) } } /// Returns the connection identifier use by this libcurl handle. pub fn conn_id(easy: &Easy) -> Result { unsafe { let conn_id: curl_off_t = 0; let rc = curl_sys::curl_easy_getinfo(easy.raw(), CURLINFO_CONN_ID, &conn_id); cvt(easy, rc)?; Ok(conn_id) } } // Timing of a typical HTTP exchange (over TLS 1.2 connection) from libcurl // (courtesy of // ========================================================================= // // ┌───────────┐ ┌──────────────┐ ┌──────────────┐ // │ Client │ │ DNS Server │ │ Web Server │ // └─────┬─────┘ └──────┬───────┘ └──────┬───────┘ // │ │ │ // ┌ 0s ├────── DNS Request ─────►│ │ // DNS │ │ │ DNS Resolver │ // Lookup < │ │ e.g. 1.1.1.1 │ // │ │◄───── DNS Response ─────┘ │ // └ time_namelookup 1.510s │ │ // ┌ ├────────────────── SYN ────────────────────►│ // TCP < │ │ // Handshake └ time_connect 1.757s │◄────────────── SYN/ACK ───────────────────┤ // ┌ │ │ // │ │ │ // │ ├────────────────── ACK ────────────────────►│ // │ ├────────────── ClientHello ────────────────►│ // │ │ │ // │ │◄───────────── ServerHello ─────────────────┤ // SSL < │ Certificate │ // Handshake │ │ │ // │ ├───────────── ClientKeyExch, ──────────────►│ // │ │ ChangeCipherSpec │ // │ │ │ // │ │◄────────── ChangeCipherSpec ───────────────┤ // └ time_appconnect 2.256s │ Finished │ // ┌ time_pretransfer 2.259s ├─────────────── HTTP GET ──────────────────►│ // │ │ │ // Wait < │ │ // │ │ │ // └ time_starttransfer 2.506s │ │ // ┌ │◄───────────────────────────────────────────┤ // Data │ │◄─────────────── Response ──────────────────┤ // Transfer < │ ... │ // │ │◄───────────────────────────────────────────┤ // └ time_total 3.001s │ │ // ▼ ▼ /// Get the name lookup time. /// /// Returns the total time in microseconds from the start until the name resolving was completed. /// /// Corresponds to [`CURLINFO_NAMELOOKUP_TIME_T`] and may return an error if the /// option isn't supported. pub fn namelookup_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_NAMELOOKUP_TIME_T).map(microseconds_to_duration) } /// Get the time until connect. /// /// Returns the total time in microseconds from the start until the connection to the remote host (or proxy) was completed. /// /// Corresponds to [`CURLINFO_CONNECT_TIME_T`] and may return an error if the /// option isn't supported. pub fn connect_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_CONNECT_TIME_T).map(microseconds_to_duration) } /// Get the time until the SSL/SSH handshake is completed. /// /// Returns the total time in microseconds it took from the start until the SSL/SSH /// connect/handshake to the remote host was completed. This time is most often /// very near to the [`pretransfer_time_t`] time, except for cases such as /// HTTP pipelining where the pretransfer time can be delayed due to waits in /// line for the pipeline and more. /// /// Corresponds to [`CURLINFO_APPCONNECT_TIME_T`] and may return an error if the /// option isn't supported. pub fn appconnect_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_APPCONNECT_TIME_T).map(microseconds_to_duration) } /// Get the time until the file transfer start. /// /// Returns the total time in microseconds it took from the start until the file /// transfer is just about to begin. This includes all pre-transfer commands /// and negotiations that are specific to the particular protocol(s) involved. /// It does not involve the sending of the protocol- specific request that /// triggers a transfer. /// /// Corresponds to [`CURLINFO_PRETRANSFER_TIME`] and may return an error if the /// option isn't supported. pub fn pretransfer_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_PRETRANSFER_TIME_T).map(microseconds_to_duration) } /// Get the time in microseconds until the first byte is received. /// /// Returns the total time it took from the start until the first /// byte is received by libcurl. This includes [`pretransfer_time_t`] and /// also the time the server needs to calculate the result. /// /// Corresponds to [`CURLINFO_STARTTRANSFER_TIME`] and may return an error if the /// option isn't supported. pub fn starttransfer_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_STARTTRANSFER_TIME_T).map(microseconds_to_duration) } /// Get total time of previous transfer /// /// Returns the total time in microseconds for the previous transfer, /// including name resolving, TCP connect etc. /// /// Corresponds to [`CURLINFO_TOTAL_TIME_T`] and may return an error if the /// option isn't supported. pub fn total_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_TOTAL_TIME_T).map(microseconds_to_duration) } /// Read .netrc information from a file. pub fn netrc_file(easy: &mut Easy, filename: &str) -> Result<(), Error> { let filename = CString::new(filename)?; cvt(easy, unsafe { curl_sys::curl_easy_setopt(easy.raw(), CURLOPT_NETRC_FILE, filename.as_ptr()) }) } /// Converts an instance of libcurl linked list [`curl_slist`] to a vec of [`String`]. fn to_list(slist: *mut curl_slist) -> Vec { let mut data = vec![]; let mut cur = slist; loop { if cur.is_null() { break; } unsafe { let ret = CStr::from_ptr((*cur).data).to_bytes(); let value = String::from_utf8_lossy(ret); data.push(value.to_string()); cur = (*cur).next; } } data } /// Check if the return code `rc` is OK, and returns an error if not. fn cvt(easy: &Easy, rc: curl_sys::CURLcode) -> Result<(), Error> { if rc == curl_sys::CURLE_OK { return Ok(()); } let mut err = Error::new(rc); if let Some(msg) = easy.take_error_buf() { err.set_extra(msg); } Err(err) } fn getopt_off_t(easy: &mut Easy, opt: CURLINFO) -> Result { unsafe { let mut p = 0 as curl_off_t; let rc = curl_sys::curl_easy_getinfo(easy.raw(), opt, &mut p); cvt(easy, rc)?; Ok(p) } } fn microseconds_to_duration(microseconds: i64) -> Duration { Duration::from_micros(microseconds as u64) } // // Iterator based implementation more similar to curl crates List implementation. // // See // pub struct CertInfo2 { // raw: *mut curl_certinfo, // } // // // An iterator over CertInfo2 // pub struct Iter<'a> { // me: &'a CertInfo2, // cur: u32, // } // // pub unsafe fn from_raw(raw: *mut curl_certinfo) -> CertInfo2 { // CertInfo2 { raw } // } // // impl CertInfo2 { // pub fn new() -> CertInfo2 { // CertInfo2 { // raw: ptr::null_mut(), // } // } // // pub fn iter(&self) -> Iter { // Iter { // me: self, // cur: 0, // } // } // } // // impl<'a> IntoIterator for &'a CertInfo2 { // type Item = *mut curl_slist; // type IntoIter = Iter<'a>; // // fn into_iter(self) -> Iter<'a> { // self.iter() // } // } // // impl<'a> Iterator for Iter<'a> { // type Item = *mut curl_slist; // // fn next(&mut self) -> Option<*mut curl_slist> { // unsafe { // if self.cur >= (*self.me.raw).num_of_certs as u32 { // return None // } // let slist = *((*self.me.raw).certinfo.offset(self.cur as isize)); // self.cur += 1; // Some(slist) // } // } // } #[cfg(test)] mod tests { use std::ffi::CString; use std::ptr; use super::to_list; #[test] fn convert_curl_slist_to_vec() { let mut slist = ptr::null_mut(); unsafe { for value in ["foo", "bar", "baz"] { let str = CString::new(value).unwrap(); slist = curl_sys::curl_slist_append(slist, str.as_ptr()); } } assert_eq!( to_list(slist), vec!["foo".to_string(), "bar".to_string(), "baz".to_string()] ); unsafe { curl_sys::curl_slist_free_all(slist); } } } hurl-7.1.0/src/http/error.rs000064400000000000000000000116661046102023000140620ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use super::request::RequestedHttpVersion; #[derive(Clone, Debug, PartialEq, Eq)] pub enum HttpError { CouldNotParseCookieExpires(String), CouldNotParseResponse, CouldNotUncompressResponse { description: String, }, InvalidCharset { charset: String, }, InvalidDecoding { charset: String, }, Libcurl { code: i32, description: String, }, LibcurlUnknownOption { option: String, minimum_version: String, }, NoPrimaryIp, TooManyRedirect, UnsupportedContentEncoding { description: String, }, UnsupportedHttpVersion(RequestedHttpVersion), /// Request URL is invalid (URL and reason) InvalidUrl(String, String), /// The maximum response size has been exceeded. /// This error can be raised even if libcurl has been configured to respect a given maximum /// file size. AllowedResponseSizeExceeded(u64), } impl From for HttpError { fn from(err: curl::Error) -> Self { let code = err.code() as i32; let description = err.description().to_string(); HttpError::Libcurl { code, description } } } impl From for HttpError { fn from(err: curl::FormError) -> Self { let code = err.code() as i32; let description = err.description().to_string(); HttpError::Libcurl { code, description } } } impl HttpError { pub fn description(&self) -> String { match self { HttpError::AllowedResponseSizeExceeded(_) => "HTTP connection".to_string(), HttpError::CouldNotParseCookieExpires(_) => "HTTP connection".to_string(), HttpError::CouldNotParseResponse => "HTTP connection".to_string(), HttpError::CouldNotUncompressResponse { .. } => "Decompression error".to_string(), HttpError::InvalidCharset { .. } => "Invalid charset".to_string(), HttpError::InvalidDecoding { .. } => "Invalid decoding".to_string(), HttpError::InvalidUrl(..) => "Invalid URL".to_string(), HttpError::Libcurl { .. } => "HTTP connection".to_string(), HttpError::LibcurlUnknownOption { .. } => "HTTP connection".to_string(), HttpError::NoPrimaryIp => "HTTP connection".to_string(), HttpError::TooManyRedirect => "HTTP connection".to_string(), HttpError::UnsupportedContentEncoding { .. } => "Decompression error".to_string(), HttpError::UnsupportedHttpVersion(_) => "Unsupported HTTP version".to_string(), } } pub fn message(&self) -> String { match self { HttpError::AllowedResponseSizeExceeded(max_size) => { format!("exceeded the maximum allowed file size ({max_size} bytes)") } HttpError::CouldNotParseCookieExpires(value) => { format!("could not parse Cookie Expires attribute value <{value}>") } HttpError::CouldNotParseResponse => "could not parse Response".to_string(), HttpError::CouldNotUncompressResponse { description } => { format!("could not uncompress response with {description}") } HttpError::InvalidCharset { charset } => { format!("the charset '{charset}' is not valid") } HttpError::InvalidDecoding { charset } => { format!("could not decode response body with charset '{charset}'") } HttpError::InvalidUrl(url, reason) => { format!("invalid URL <{url}> ({reason})").to_string() } HttpError::Libcurl { code, description } => format!("({code}) {description}"), HttpError::LibcurlUnknownOption { option, minimum_version, } => format!("Option {option} requires libcurl version {minimum_version} or higher"), HttpError::NoPrimaryIp => "No primary IP found in response".to_string(), HttpError::TooManyRedirect => "too many redirect".to_string(), HttpError::UnsupportedHttpVersion(version) => { format!("{version} is not supported, check --version").to_string() } HttpError::UnsupportedContentEncoding { description } => { format!("compression {description} is not supported").to_string() } } } } hurl-7.1.0/src/http/header.rs000064400000000000000000000152571046102023000141610ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use core::fmt; use std::slice::Iter; /// See pub const ACCEPT_ENCODING: &str = "Accept-Encoding"; /// See pub const AUTHORIZATION: &str = "Authorization"; /// See pub const COOKIE: &str = "Cookie"; /// See pub const CONTENT_ENCODING: &str = "Content-Encoding"; /// See pub const CONTENT_TYPE: &str = "Content-Type"; /// See pub const EXPECT: &str = "Expect"; /// See pub const LOCATION: &str = "Location"; /// See pub const SET_COOKIE: &str = "Set-Cookie"; /// See pub const USER_AGENT: &str = "User-Agent"; /// Represents an HTTP header. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Header { pub name: String, pub value: String, } impl fmt::Display for Header { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: {}", self.name, self.value) } } impl Header { /// Creates an HTTP header with this `name`and `value`. pub fn new(name: &str, value: &str) -> Self { Header { name: name.to_string(), value: value.to_string(), } } /// Returns `true` if this HTTP header name is equal to `name`. /// /// An HTTP header consists of a case-insensitive name. pub fn name_eq(&self, name: &str) -> bool { self.name.to_lowercase() == name.to_lowercase() } } /// Represents an ordered list of [`Header`]. /// The headers are sorted by insertion order. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct HeaderVec { headers: Vec
, } impl HeaderVec { /// Creates an empty [`HeaderVec`]. pub fn new() -> Self { HeaderVec::default() } /// Returns a reference to the header associated with `name`. /// /// If there are multiple headers associated with `name`, then the first one is returned. /// Use [HeaderVec::get_all] to get all values associated with a given key. pub fn get(&self, name: &str) -> Option<&Header> { self.headers.iter().find(|h| h.name_eq(name)) } /// Returns a list of header associated with `name`. pub fn get_all(&self, name: &str) -> Vec<&Header> { self.headers.iter().filter(|h| h.name_eq(name)).collect() } /// Returns true if there is at least one header with the specified `name`. pub fn contains_key(&self, name: &str) -> bool { self.headers.iter().any(|h| h.name_eq(name)) } /// Retains only the header specified by the predicate. pub fn retain(&mut self, mut f: F) where F: FnMut(&Header) -> bool, { self.headers.retain(|h| f(h)); } /// Returns an iterator over all the headers. pub fn iter(&self) -> impl Iterator { self.headers.iter() } /// Returns the number of headers stored in the list. /// /// This number represents the total numbers of header, including header with the same name and /// different values. pub fn len(&self) -> usize { self.headers.len() } /// Returns true if there is no header. pub fn is_empty(&self) -> bool { self.headers.len() == 0 } /// Push a new `header` into the headers list. pub fn push(&mut self, header: Header) { self.headers.push(header); } /// Returns all headers values. pub fn values(&self, name: &str) -> Vec<&str> { self.get_all(name) .iter() .map(|h| h.value.as_str()) .collect::>() } } impl<'a> IntoIterator for &'a HeaderVec { type Item = &'a Header; type IntoIter = Iter<'a, Header>; fn into_iter(self) -> Self::IntoIter { self.headers.iter() } } #[cfg(test)] mod tests { use crate::http::header::HeaderVec; use crate::http::Header; #[test] fn test_simple_header_map() { let mut headers = HeaderVec::new(); headers.push(Header::new("foo", "xxx")); headers.push(Header::new("bar", "yyy0")); headers.push(Header::new("bar", "yyy1")); headers.push(Header::new("bar", "yyy2")); headers.push(Header::new("baz", "zzz")); assert_eq!(headers.len(), 5); assert!(!headers.is_empty()); assert_eq!(headers.get("foo"), Some(&Header::new("foo", "xxx"))); assert_eq!(headers.get("FOO"), Some(&Header::new("foo", "xxx"))); assert_eq!(headers.get("bar"), Some(&Header::new("bar", "yyy0"))); assert_eq!(headers.get("qux"), None); assert_eq!( headers.get_all("bar"), vec![ &Header::new("bar", "yyy0"), &Header::new("bar", "yyy1"), &Header::new("bar", "yyy2"), ] ); assert_eq!(headers.get_all("BAZ"), vec![&Header::new("baz", "zzz")]); assert_eq!(headers.get_all("qux"), Vec::<&Header>::new()); assert!(headers.contains_key("FOO")); assert!(!headers.contains_key("fuu")); headers.retain(|h| h.name_eq("Bar")); assert_eq!(headers.len(), 3); } #[test] fn test_iter() { let data = [("foo", "xxx"), ("bar", "yyy0"), ("baz", "yyy1")]; let mut headers = HeaderVec::new(); data.iter() .for_each(|(name, value)| headers.push(Header::new(name, value))); // Test iter() for (i, h) in headers.iter().enumerate() { assert_eq!(h.name, data[i].0); assert_eq!(h.value, data[i].1); } // Test into_iter() for (i, h) in (&headers).into_iter().enumerate() { assert_eq!(h.name, data[i].0); assert_eq!(h.value, data[i].1); } } } hurl-7.1.0/src/http/headers_helper.rs000064400000000000000000000130271046102023000156740ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use encoding_rs::Encoding; use super::error::HttpError; use super::header::{Header, HeaderVec, CONTENT_ENCODING, CONTENT_TYPE}; use super::mimetype; use super::response_decoding::ContentEncoding; impl HeaderVec { /// Returns optional Content-type header value. pub fn content_type(&self) -> Option<&str> { self.get(CONTENT_TYPE).map(|h| h.value.as_str()) } /// Returns character encoding from this list of headers. /// /// If no character encoding can be found, returns UTF-8. pub fn character_encoding(&self) -> Result<&'static Encoding, HttpError> { match self.content_type() { Some(content_type) => match mimetype::charset(content_type) { Some(charset) => match Encoding::for_label(charset.as_bytes()) { None => Err(HttpError::InvalidCharset { charset }), Some(enc) => Ok(enc), }, None => Ok(encoding_rs::UTF_8), }, None => Ok(encoding_rs::UTF_8), } } /// Returns list of content encoding from HTTP response headers. /// /// See pub fn content_encoding(&self) -> Result, HttpError> { for header in self { if header.name_eq(CONTENT_ENCODING) { let mut encodings = vec![]; for value in header.value.split(',') { let encoding = ContentEncoding::parse(value.trim())?; encodings.push(encoding); } return Ok(encodings); } } Ok(vec![]) } /// Returns a new list of headers with the headers from `self` and the raw headers `raw_headers`. pub fn with_raw_headers(&self, raw_headers: &[&str]) -> HeaderVec { let mut headers = self.clone(); // TODO: use another function that [`Header::parse`] because [`Header::parse`] is for // parsing headers line coming from a server (and not from options header) let raw_headers = raw_headers.iter().filter_map(|h| Header::parse(h)); for header in raw_headers { headers.push(header); } headers } } #[cfg(test)] mod tests { use crate::http::response_decoding::ContentEncoding; use crate::http::{Header, HeaderVec}; #[test] fn content_type_basic() { let mut headers = HeaderVec::new(); headers.push(Header::new("Host", "localhost:8000")); headers.push(Header::new("Accept", "*/*")); headers.push(Header::new("User-Agent", "hurl/1.0")); headers.push(Header::new("content-type", "application/json")); assert_eq!(headers.content_type(), Some("application/json")); let mut headers = HeaderVec::new(); headers.push(Header::new("foo", "bar")); assert_eq!(headers.content_type(), None); } #[test] fn content_encoding() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "deflate, gzip")); assert_eq!( headers.content_encoding(), Ok(vec![ContentEncoding::Deflate, ContentEncoding::Gzip]) ); } #[test] fn character_encoding() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "text/html; charset=utf-8")); assert_eq!(headers.character_encoding().unwrap(), encoding_rs::UTF_8); let mut headers = HeaderVec::new(); headers.push(Header::new("content-type", "text/plain; charset=us-ascii")); assert_eq!( headers.character_encoding().unwrap(), encoding_rs::WINDOWS_1252 ); let mut headers = HeaderVec::new(); headers.push(Header::new("content-type", "text/plain")); assert_eq!(headers.character_encoding().unwrap(), encoding_rs::UTF_8); } #[test] fn test_with_raw_headers() { let mut headers = HeaderVec::new(); headers.push(Header::new("Host", "localhost:8000")); headers.push(Header::new("Repeated-Header", "original")); let raw_headers = &[ "User-Agent: hurl/6.1.0", "Invalid-Header", "Repeated-Header: aggregated-1", "Repeated-Header: aggregated-2", ]; let headers = headers.with_raw_headers(raw_headers); assert_eq!( headers.get("Host"), Some(&Header::new("Host", "localhost:8000")) ); assert_eq!( headers.get("User-Agent"), Some(&Header::new("User-Agent", "hurl/6.1.0")) ); assert_eq!(headers.get("Invalid-Header"), None); assert_eq!( headers.get_all("Repeated-Header"), vec![ &Header::new("Repeated-Header", "original"), &Header::new("Repeated-Header", "aggregated-1"), &Header::new("Repeated-Header", "aggregated-2") ] ); } } hurl-7.1.0/src/http/ip.rs000064400000000000000000000025611046102023000133330ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::fmt::{Display, Formatter}; /// An IP address, either IPv4 or IPv6. /// /// The `raw` field of this structure comes from libcurl `as is`. We keep it as a /// [`String`] instead of a [`std::net::IpAddr`] to not make any assumptions /// of the address format. We don't want to invalidate an HTTP exchange and raise a /// runtime error because of an unusual format coming from libcurl. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct IpAddr { raw: String, } impl IpAddr { /// Creates a new IP address from a raw string (from libcurl). pub fn new(raw: String) -> IpAddr { IpAddr { raw } } } impl Display for IpAddr { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.raw) } } hurl-7.1.0/src/http/mimetype.rs000064400000000000000000001033371046102023000145570ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ // TODO: maybe add a proper MimeType enum (see ) // as implementation / api example. use regex::Regex; /// Returns true if binary data with this `content_type` can be decoded as text. pub fn is_kind_of_text(content_type: &str) -> bool { let content_type = content_type.trim().to_lowercase(); content_type.contains("text/") || is_json(&content_type) || is_xml(&content_type) } /// Returns true if this `content_type` is HTML. pub fn is_html(content_type: &str) -> bool { let content_type = content_type.trim().to_lowercase(); content_type.starts_with("text/html") } /// Returns true if this `content_type` is HTML. pub fn is_xml(content_type: &str) -> bool { let content_type = content_type.trim().to_lowercase(); let patterns = [ r"^text/xml", r"^application/xml", r"^application/[a-z0-9-_.]+[+.]xml", ]; patterns .iter() .any(|p| Regex::new(p).unwrap().is_match(&content_type)) } /// Returns true if this `content_type` is JSON. pub fn is_json(content_type: &str) -> bool { let content_type = content_type.trim().to_lowercase(); let patterns = [r"^application/json", r"^application/[a-z0-9-_.]+[+.-]json"]; patterns .iter() .any(|p| Regex::new(p).unwrap().is_match(&content_type)) } /// Extracts charset from mime-type String pub fn charset(mime_type: &str) -> Option { let parts = mime_type.trim().split(';'); for part in parts { let param = part.trim().split('=').collect::>(); if param.len() == 2 && param[0].trim().eq_ignore_ascii_case("charset") { return Some(param[1].trim().to_string()); } } None } #[cfg(test)] pub mod tests { use super::*; #[test] fn test_charset() { assert_eq!( charset("text/plain; charset=utf-8"), Some("utf-8".to_string()) ); assert_eq!( charset("text/plain; charset=ISO-8859-1"), Some("ISO-8859-1".to_string()) ); assert_eq!(charset("text/plain;"), None); assert_eq!( charset("text/plain; CHARSET=ISO-8859-1"), Some("ISO-8859-1".to_string()) ); assert_eq!( charset("text/plain; version=0.0.4; charset=utf-8; escaping=values"), Some("utf-8".to_string()) ); } // Dataset for mimetypes issued from #[test] fn test_is_json() { let mime_types = [ "application/3gppHal+json", "application/3gppHalForms+json", "application/ace+json", "application/activity+json", "application/aif+json", "application/alto-cdni+json", "application/alto-cdnifilter+json", "application/alto-costmap+json", "application/alto-costmapfilter+json", "application/alto-directory+json", "application/alto-endpointprop+json", "application/alto-endpointpropparams+json", "application/alto-endpointcost+json", "application/alto-endpointcostparams+json", "application/alto-error+json", "application/alto-networkmapfilter+json", "application/alto-networkmap+json", "application/alto-propmap+json", "application/alto-propmapparams+json", "application/alto-tips+json", "application/alto-tipsparams+json", "application/alto-updatestreamcontrol+json", "application/alto-updatestreamparams+json", "application/atsc-rdt+json", "application/calendar+json", "application/captive+json", "application/city+json", "application/coap-group+json", "application/csvm+json", "application/cwl+json", "application/dicom+json", "application/dns+json", "application/elm+json", "application/EmergencyCallData.LegacyESN+json", "application/expect-ct-report+json", "application/fhir+json", "application/geo+json", "application/geo+json-seq", "application/geopose+json", "application/geoxacml+json", "application/jf2feed+json", "application/jose+json", "application/jrd+json", "application/jscalendar+json", "application/jscontact+json", "application/json", "application/json-patch+json", "application/json-seq", "application/jsonpath", "application/jwk+json", "application/jwk-set+json", "application/ld+json", "application/linkset+json", "application/manifest+json", "application/merge-patch+json", "application/mud+json", "application/ppsp-tracker+json", "application/problem+json", "application/prs.implied-object+json", "application/prs.implied-object+json-seq", "application/pvd+json", "application/rdap+json", "application/reputon+json", "application/sarif-external-properties+json", "application/sarif+json", "application/scim+json", "application/senml-etch+json", "application/senml+json", "application/sensml+json", "application/spdx+json", "application/stix+json", "application/taxii+json", "application/td+json", "application/tlsrpt+json", "application/tm+json", "application/trust-chain+json", "application/vcard+json", "application/vnd.acm.addressxfer+json", "application/vnd.acm.chatbot+json", "application/vnd.amadeus+json", "application/vnd.apache.thrift.json", "application/vnd.api+json", "application/vnd.aplextor.warrp+json", "application/vnd.apothekende.reservation+json", "application/vnd.artisan+json", "application/vnd.avalon+json", "application/vnd.bbf.usp.msg+json", "application/vnd.bekitzur-stech+json", "application/vnd.byu.uapi+json", "application/vnd.capasystems-pg+json", "application/vnd.cncf.helm.config.v1+json", "application/vnd.collection.doc+json", "application/vnd.collection+json", "application/vnd.collection.next+json", "application/vnd.coreos.ignition+json", "application/vnd.cryptii.pipe+json", "application/vnd.cyclonedx+json", "application/vnd.datapackage+json", "application/vnd.dataresource+json", "application/vnd.document+json", "application/vnd.drive+json", "application/vnd.eclipse.ditto+json", "application/vnd.eu.kasparian.car+json", "application/vnd.futoin+json", "application/vnd.gentics.grd+json", "application/vnd.geo+json", "application/vnd.gnu.taler.exchange+json", "application/vnd.gnu.taler.merchant+json", "application/vnd.hal+json", "application/vnd.hc+json", "application/vnd.heroku+json", "application/vnd.hyper-item+json", "application/vnd.hyper+json", "application/vnd.hyperdrive+json", "application/vnd.ims.lis.v2.result+json", "application/vnd.ims.lti.v2.toolconsumerprofile+json", "application/vnd.ims.lti.v2.toolproxy.id+json", "application/vnd.ims.lti.v2.toolproxy+json", "application/vnd.ims.lti.v2.toolsettings+json", "application/vnd.ims.lti.v2.toolsettings.simple+json", "application/vnd.ipld.dag-json", "application/vnd.las.las+json", "application/vnd.leap+json", "application/vnd.mason+json", "application/vnd.micro+json", "application/vnd.miele+json", "application/vnd.nacamar.ybrid+json", "application/vnd.nato.bindingdataobject+json", "application/vnd.nearst.inv+json", "application/vnd.oai.workflows+json", "application/vnd.oci.image.manifest.v1+json", "application/vnd.oftn.l10n+json", "application/vnd.oma.lwm2m+json", "application/vnd.openvpi.dspx+json", "application/vnd.oracle.resource+json", "application/vnd.pagerduty+json", "application/vnd.restful+json", "application/vnd.seis+json", "application/vnd.shootproof+json", "application/vnd.shopkick+json", "application/vnd.siren+json", "application/vnd.syft+json", "application/vnd.tableschema+json", "application/vnd.think-cell.ppttc+json", "application/vnd.uic.osdm+json", "application/vnd.vel+json", "application/vnd.veritone.aion+json", "application/vnd.xacml+json", "application/voucher-cms+json", "application/webpush-options+json", "application/yang-data+json", "application/yang-patch+json", "application/yang-sid+json", ]; for mime in &mime_types { assert!(is_json(mime), "{mime} is a not a JSON mime type"); } } #[test] fn test_is_xml() { let mime_types = [ "application/3gpdash-qoe-report+xml", "application/3gpp-ims+xml", "application/atom+xml", "application/atomcat+xml", "application/atomdeleted+xml", "application/atomsvc+xml", "application/atsc-dwd+xml", "application/atsc-held+xml", "application/atsc-rsat+xml", "application/auth-policy+xml", "application/automationml-aml+xml", "application/beep+xml", "application/calendar+xml", "application/ccmp+xml", "application/ccxml+xml", "application/cda+xml", "application/cea-2018+xml", "application/cellml+xml", "application/clue_info+xml", "application/clue+xml", "application/cnrp+xml", "application/conference-info+xml", "application/cpl+xml", "application/csta+xml", "application/CSTAdata+xml", "application/dash+xml", "application/dash-patch+xml", "application/davmount+xml", "application/dialog-info+xml", "application/dicom+xml", "application/dskpp+xml", "application/dssc+xml", "application/elm+xml", "application/EmergencyCallData.cap+xml", "application/EmergencyCallData.Comment+xml", "application/EmergencyCallData.Control+xml", "application/EmergencyCallData.DeviceInfo+xml", "application/EmergencyCallData.ProviderInfo+xml", "application/EmergencyCallData.ServiceInfo+xml", "application/EmergencyCallData.SubscriberInfo+xml", "application/EmergencyCallData.VEDS+xml", "application/emma+xml", "application/emotionml+xml", "application/epp+xml", "application/fdt+xml", "application/fhir+xml", "application/framework-attributes+xml", "application/geoxacml+xml", "application/gml+xml", "application/held+xml", "application/hl7v2+xml", "application/ibe-key-request+xml", "application/ibe-pkg-reply+xml", "application/im-iscomposing+xml", "application/inkml+xml", "application/its+xml", "application/kpml-request+xml", "application/kpml-response+xml", "application/lgr+xml", "application/load-control+xml", "application/lost+xml", "application/lostsync+xml", "application/mads+xml", "application/marcxml+xml", "application/mathml+xml", "application/mathml-content+xml", "application/mathml-presentation+xml", "application/mbms-associated-procedure-description+xml", "application/mbms-deregister+xml", "application/mbms-envelope+xml", "application/mbms-msk-response+xml", "application/mbms-msk+xml", "application/mbms-protection-description+xml", "application/mbms-reception-report+xml", "application/mbms-register-response+xml", "application/mbms-register+xml", "application/mbms-schedule+xml", "application/mbms-user-service-description+xml", "application/media_control+xml", "application/media-policy-dataset+xml", "application/mediaservercontrol+xml", "application/metalink4+xml", "application/mets+xml", "application/mmt-aei+xml", "application/mmt-usd+xml", "application/mods+xml", "application/mrb-consumer+xml", "application/mrb-publish+xml", "application/msc-ivr+xml", "application/msc-mixer+xml", "application/nlsml+xml", "application/odm+xml", "application/oebps-package+xml", "application/opc-nodeset+xml", "application/p2p-overlay+xml", "application/patch-ops-error+xml", "application/pidf-diff+xml", "application/pidf+xml", "application/pls+xml", "application/poc-settings+xml", "application/problem+xml", "application/provenance+xml", "application/prs.implied-document+xml", "application/prs.xsf+xml", "application/pskc+xml", "application/rdf+xml", "application/route-apd+xml", "application/route-s-tsid+xml", "application/route-usd+xml", "application/reginfo+xml", "application/resource-lists-diff+xml", "application/resource-lists+xml", "application/rfc+xml", "application/rlmi+xml", "application/rls-services+xml", "application/samlassertion+xml", "application/samlmetadata+xml", "application/sbml+xml", "application/scaip+xml", "application/senml+xml", "application/sensml+xml", "application/sep+xml", "application/shf+xml", "application/simple-filter+xml", "application/smil+xml", "application/soap+xml", "application/sparql-results+xml", "application/spirits-event+xml", "application/srgs+xml", "application/sru+xml", "application/ssml+xml", "application/swid+xml", "application/tei+xml", "application/thraud+xml", "application/ttml+xml", "application/urc-grpsheet+xml", "application/urc-ressheet+xml", "application/urc-targetdesc+xml", "application/urc-uisocketdesc+xml", "application/vcard+xml", "application/vnd.1000minds.decision-model+xml", "application/vnd.3gpp.access-transfer-events+xml", "application/vnd.3gpp.bsf+xml", "application/vnd.3gpp.crs+xml", "application/vnd.3gpp.current-location-discovery+xml", "application/vnd.3gpp.GMOP+xml", "application/vnd.3gpp.mcdata-affiliation-command+xml", "application/vnd.3gpp.mcdata-info+xml", "application/vnd.3gpp.mcdata-msgstore-ctrl-request+xml", "application/vnd.3gpp.mcdata-regroup+xml", "application/vnd.3gpp.mcdata-service-config+xml", "application/vnd.3gpp.mcdata-ue-config+xml", "application/vnd.3gpp.mcdata-user-profile+xml", "application/vnd.3gpp.mcptt-affiliation-command+xml", "application/vnd.3gpp.mcptt-floor-request+xml", "application/vnd.3gpp.mcptt-info+xml", "application/vnd.3gpp.mcptt-location-info+xml", "application/vnd.3gpp.mcptt-mbms-usage-info+xml", "application/vnd.3gpp.mcptt-regroup+xml", "application/vnd.3gpp.mcptt-service-config+xml", "application/vnd.3gpp.mcptt-signed+xml", "application/vnd.3gpp.mcptt-ue-config+xml", "application/vnd.3gpp.mcptt-ue-init-config+xml", "application/vnd.3gpp.mcptt-user-profile+xml", "application/vnd.3gpp.mcvideo-affiliation-command+xml", "application/vnd.3gpp.mcvideo-affiliation-info+xml", "application/vnd.3gpp.mcvideo-info+xml", "application/vnd.3gpp.mcvideo-location-info+xml", "application/vnd.3gpp.mcvideo-mbms-usage-info+xml", "application/vnd.3gpp.mcvideo-regroup+xml", "application/vnd.3gpp.mcvideo-service-config+xml", "application/vnd.3gpp.mcvideo-transmission-request+xml", "application/vnd.3gpp.mcvideo-ue-config+xml", "application/vnd.3gpp.mcvideo-user-profile+xml", "application/vnd.3gpp.mid-call+xml", "application/vnd.3gpp.pinapp-info+xml", "application/vnd.3gpp-prose-pc3a+xml", "application/vnd.3gpp-prose-pc3ach+xml", "application/vnd.3gpp-prose-pc3ch+xml", "application/vnd.3gpp-prose-pc8+xml", "application/vnd.3gpp-prose+xml", "application/vnd.3gpp.seal-group-doc+xml", "application/vnd.3gpp.seal-info+xml", "application/vnd.3gpp.seal-location-info+xml", "application/vnd.3gpp.seal-mbms-usage-info+xml", "application/vnd.3gpp.seal-network-QoS-management-info+xml", "application/vnd.3gpp.seal-ue-config-info+xml", "application/vnd.3gpp.seal-unicast-info+xml", "application/vnd.3gpp.seal-user-profile-info+xml", "application/vnd.3gpp.sms+xml", "application/vnd.3gpp.srvcc-ext+xml", "application/vnd.3gpp.SRVCC-info+xml", "application/vnd.3gpp.state-and-event-info+xml", "application/vnd.3gpp.ussd+xml", "application/vnd.3gpp.vae-info+xml", "application/vnd.3gpp2.bcmcsinfo+xml", "application/vnd.adobe.xdp+xml", "application/vnd.amundsen.maze+xml", "application/vnd.apple.installer+xml", "application/vnd.avistar+xml", "application/vnd.balsamiq.bmml+xml", "application/vnd.biopax.rdf+xml", "application/vnd.c3voc.schedule+xml", "application/vnd.chemdraw+xml", "application/vnd.citationstyles.style+xml", "application/vnd.criticaltools.wbs+xml", "application/vnd.ctct.ws+xml", "application/vnd.cyan.dean.root+xml", "application/vnd.cyclonedx+xml", "application/vnd.dece.ttml+xml", "application/vnd.dm.delegation+xml", "application/vnd.dvb.dvbisl+xml", "application/vnd.dvb.notif-aggregate-root+xml", "application/vnd.dvb.notif-container+xml", "application/vnd.dvb.notif-generic+xml", "application/vnd.dvb.notif-ia-msglist+xml", "application/vnd.dvb.notif-ia-registration-request+xml", "application/vnd.dvb.notif-ia-registration-response+xml", "application/vnd.dvb.notif-init+xml", "application/vnd.emclient.accessrequest+xml", "application/vnd.eprints.data+xml", "application/vnd.eszigno3+xml", "application/vnd.etsi.aoc+xml", "application/vnd.etsi.cug+xml", "application/vnd.etsi.iptvcommand+xml", "application/vnd.etsi.iptvdiscovery+xml", "application/vnd.etsi.iptvprofile+xml", "application/vnd.etsi.iptvsad-bc+xml", "application/vnd.etsi.iptvsad-cod+xml", "application/vnd.etsi.iptvsad-npvr+xml", "application/vnd.etsi.iptvservice+xml", "application/vnd.etsi.iptvsync+xml", "application/vnd.etsi.iptvueprofile+xml", "application/vnd.etsi.mcid+xml", "application/vnd.etsi.overload-control-policy-dataset+xml", "application/vnd.etsi.pstn+xml", "application/vnd.etsi.sci+xml", "application/vnd.etsi.simservs+xml", "application/vnd.etsi.tsl+xml", "application/vnd.fujifilm.fb.jfi+xml", "application/vnd.gentoo.catmetadata+xml", "application/vnd.gentoo.pkgmetadata+xml", "application/vnd.geocube+xml", "application/vnd.google-earth.kml+xml", "application/vnd.gov.sk.e-form+xml", "application/vnd.gov.sk.xmldatacontainer+xml", "application/vnd.gpxsee.map+xml", "application/vnd.hal+xml", "application/vnd.HandHeld-Entertainment+xml", "application/vnd.informedcontrol.rms+xml", "application/vnd.infotech.project+xml", "application/vnd.iptc.g2.catalogitem+xml", "application/vnd.iptc.g2.conceptitem+xml", "application/vnd.iptc.g2.knowledgeitem+xml", "application/vnd.iptc.g2.newsitem+xml", "application/vnd.iptc.g2.newsmessage+xml", "application/vnd.iptc.g2.packageitem+xml", "application/vnd.iptc.g2.planningitem+xml", "application/vnd.irepository.package+xml", "application/vnd.las.las+xml", "application/vnd.liberty-request+xml", "application/vnd.llamagraphics.life-balance.exchange+xml", "application/vnd.marlin.drm.actiontoken+xml", "application/vnd.marlin.drm.conftoken+xml", "application/vnd.marlin.drm.license+xml", "application/vnd.mozilla.xul+xml", "application/vnd.ms-office.activeX+xml", "application/vnd.ms-playready.initiator+xml", "application/vnd.ms-PrintDeviceCapabilities+xml", "application/vnd.ms-PrintSchemaTicket+xml", "application/vnd.nato.bindingdataobject+xml", "application/vnd.nokia.conml+xml", "application/vnd.nokia.iptv.config+xml", "application/vnd.nokia.landmark+xml", "application/vnd.nokia.landmarkcollection+xml", "application/vnd.nokia.n-gage.ac+xml", "application/vnd.nokia.pcd+xml", "application/vnd.oipf.contentaccessdownload+xml", "application/vnd.oipf.contentaccessstreaming+xml", "application/vnd.oipf.dae.svg+xml", "application/vnd.oipf.dae.xhtml+xml", "application/vnd.oipf.mippvcontrolmessage+xml", "application/vnd.oipf.spdiscovery+xml", "application/vnd.oipf.spdlist+xml", "application/vnd.oipf.ueprofile+xml", "application/vnd.oipf.userprofile+xml", "application/vnd.oma.bcast.associated-procedure-parameter+xml", "application/vnd.oma.bcast.drm-trigger+xml", "application/vnd.oma.bcast.imd+xml", "application/vnd.oma.bcast.notification+xml", "application/vnd.oma.bcast.sgdd+xml", "application/vnd.oma.bcast.smartcard-trigger+xml", "application/vnd.oma.bcast.sprov+xml", "application/vnd.oma.cab-address-book+xml", "application/vnd.oma.cab-feature-handler+xml", "application/vnd.oma.cab-pcc+xml", "application/vnd.oma.cab-subs-invite+xml", "application/vnd.oma.cab-user-prefs+xml", "application/vnd.oma.dd2+xml", "application/vnd.oma.drm.risd+xml", "application/vnd.oma.group-usage-list+xml", "application/vnd.oma.pal+xml", "application/vnd.oma.poc.detailed-progress-report+xml", "application/vnd.oma.poc.final-report+xml", "application/vnd.oma.poc.groups+xml", "application/vnd.oma.poc.invocation-descriptor+xml", "application/vnd.oma.poc.optimized-progress-report+xml", "application/vnd.oma.scidm.messages+xml", "application/vnd.oma.xcap-directory+xml", "application/vnd.omads-email+xml", "application/vnd.omads-file+xml", "application/vnd.omads-folder+xml", "application/vnd.openblox.game+xml", "application/vnd.openstreetmap.data+xml", "application/vnd.openxmlformats-officedocument.custom-properties+xml", "application/vnd.openxmlformats-officedocument.customXmlProperties+xml", "application/vnd.openxmlformats-officedocument.drawing+xml", "application/vnd.openxmlformats-officedocument.drawingml.chart+xml", "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml", "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml", "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml", "application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml", "application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml", "application/vnd.openxmlformats-officedocument.extended-properties+xml", "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml", "application/vnd.openxmlformats-officedocument.presentationml.comments+xml", "application/vnd.openxmlformats-officedocument.presentationml.handoutMaster+xml", "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml", "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml", "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml", "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml", "application/vnd.openxmlformats-officedocument.presentationml.slide+xml", "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml", "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml", "application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml", "application/vnd.openxmlformats-officedocument.presentationml.slideUpdateInfo+xml", "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml", "application/vnd.openxmlformats-officedocument.presentationml.tags+xml", "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml", "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionHeaders+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionLog+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.tableSingleCells+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.volatileDependencies+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", "application/vnd.openxmlformats-officedocument.theme+xml", "application/vnd.openxmlformats-officedocument.themeOverride+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml", "application/vnd.openxmlformats-package.core-properties+xml", "application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml", "application/vnd.openxmlformats-package.relationships+xml", "application/vnd.otps.ct-kip+xml", "application/vnd.paos.xml", "application/vnd.poc.group-advertisement+xml", "application/vnd.pwg-xhtml-print+xml", "application/vnd.radisys.moml+xml", "application/vnd.radisys.msml-audit-conf+xml", "application/vnd.radisys.msml-audit-conn+xml", "application/vnd.radisys.msml-audit-dialog+xml", "application/vnd.radisys.msml-audit-stream+xml", "application/vnd.radisys.msml-audit+xml", "application/vnd.radisys.msml-conf+xml", "application/vnd.radisys.msml-dialog-base+xml", "application/vnd.radisys.msml-dialog-fax-detect+xml", "application/vnd.radisys.msml-dialog-fax-sendrecv+xml", "application/vnd.radisys.msml-dialog-group+xml", "application/vnd.radisys.msml-dialog-speech+xml", "application/vnd.radisys.msml-dialog-transform+xml", "application/vnd.radisys.msml-dialog+xml", "application/vnd.radisys.msml+xml", "application/vnd.recordare.musicxml+xml", "application/vnd.route66.link66+xml", "application/vnd.software602.filler.form+xml", "application/vnd.solent.sdkm+xml", "application/vnd.sun.wadl+xml", "application/vnd.sycle+xml", "application/vnd.syncml.dmddf+xml", "application/vnd.syncml.dmtnds+xml", "application/vnd.syncml.dm+xml", "application/vnd.syncml+xml", "application/vnd.tmd.mediaflex.api+xml", "application/vnd.uoml+xml", "application/vnd.wv.csp+xml", "application/vnd.wv.ssp+xml", "application/vnd.xmi+xml", "application/vnd.yamaha.openscoreformat.osfpvg+xml", "application/vnd.zzazz.deck+xml", "application/voicexml+xml", "application/watcherinfo+xml", "application/wsdl+xml", "application/wspolicy+xml", "application/xacml+xml", "application/xcap-att+xml", "application/xcap-caps+xml", "application/xcap-diff+xml", "application/xcap-el+xml", "application/xcap-error+xml", "application/xcap-ns+xml", "application/xcon-conference-info-diff+xml", "application/xcon-conference-info+xml", "application/xenc+xml", "application/xhtml+xml", "application/xliff+xml", "application/xml", "application/xml-dtd", "application/xml-external-parsed-entity", "application/xml-patch+xml", "application/xmpp+xml", "application/xop+xml", "application/xslt+xml", "application/xv+xml", "application/yang-data+xml", "application/yang-patch+xml", "application/yin+xml", ]; for mime in &mime_types { assert!(is_xml(mime), "{mime} is a not a XML mime type"); } } } hurl-7.1.0/src/http/mod.rs000064400000000000000000000041241046102023000134770ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! Various HTTP structures like requests, responses, cookies etc. //! //! The Hurl HTTP engine is not public. It's a wrapper around libcurl and only the models //! returned by an HTTP exchange are exposed. pub use self::call::Call; pub use self::certificate::Certificate; pub(crate) use self::client::Client; pub use self::cookie_store::Cookie; pub use self::curl_cmd::CurlCmd; pub(crate) use self::error::HttpError; pub use self::header::{ Header, HeaderVec, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, COOKIE, EXPECT, USER_AGENT, }; pub(crate) use self::options::{ClientOptions, Verbosity}; pub(crate) use self::param::Param; pub use self::request::{IpResolve, Request, RequestedHttpVersion}; pub(crate) use self::request_cookie::RequestCookie; pub(crate) use self::request_spec::{Body, FileParam, Method, MultipartParam, RequestSpec}; pub use self::response::{HttpVersion, Response}; pub use self::response_cookie::{CookieAttribute, ResponseCookie}; #[cfg(test)] pub use self::tests::*; pub use self::timings::Timings; pub use self::url::{Url, UrlError}; pub use self::version::libcurl_version_info; mod call; mod certificate; mod client; mod cookie_store; mod curl_cmd; mod debug; mod easy_ext; mod error; mod header; mod headers_helper; mod ip; mod mimetype; mod options; mod param; mod request; mod request_cookie; mod request_spec; mod response; mod response_cookie; mod response_debug; mod response_decoding; #[cfg(test)] mod tests; mod timings; mod timings_debug; mod url; mod version; hurl-7.1.0/src/http/options.rs000064400000000000000000000073551046102023000144240ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::time::Duration; use hurl_core::types::{BytesPerSec, Count}; use super::request::{IpResolve, RequestedHttpVersion}; #[derive(Debug, Clone)] pub struct ClientOptions { /// Allow reusing internal connections, `true` by default. Setting this to `false` forces the /// HTTP client to use a new HTTP connection, and also marks this new connection as not reusable. /// Under the hood, this activates libcurl [`CURLOPT_FRESH_CONNECT`](https://curl.se/libcurl/c/CURLOPT_FRESH_CONNECT.html) /// and [`CURLOPT_FORBID_REUSE`](https://curl.se/libcurl/c/CURLOPT_FORBID_REUSE.html). pub allow_reuse: bool, pub aws_sigv4: Option, pub cacert_file: Option, pub client_cert_file: Option, pub client_key_file: Option, pub compressed: bool, pub connect_timeout: Duration, pub connects_to: Vec, pub cookie_input_file: Option, pub follow_location: bool, pub follow_location_trusted: bool, pub headers: Vec, pub http_version: RequestedHttpVersion, pub insecure: bool, pub ip_resolve: IpResolve, pub max_filesize: Option, pub max_recv_speed: Option, pub max_redirect: Count, pub max_send_speed: Option, pub negotiate: bool, pub netrc: bool, pub netrc_file: Option, pub netrc_optional: bool, pub no_proxy: Option, pub ntlm: bool, pub path_as_is: bool, pub pinned_pub_key: Option, pub proxy: Option, pub resolves: Vec, pub ssl_no_revoke: bool, pub timeout: Duration, pub unix_socket: Option, pub user: Option, pub user_agent: Option, pub verbosity: Option, } // FIXME/ we could implement copy here #[derive(Clone, Debug, PartialEq, Eq)] pub enum Verbosity { Verbose, VeryVerbose, } impl Default for ClientOptions { fn default() -> Self { ClientOptions { allow_reuse: true, aws_sigv4: None, cacert_file: None, client_cert_file: None, client_key_file: None, compressed: false, connect_timeout: Duration::from_secs(300), connects_to: vec![], cookie_input_file: None, follow_location: false, follow_location_trusted: false, headers: vec![], http_version: RequestedHttpVersion::default(), insecure: false, ip_resolve: IpResolve::default(), max_filesize: None, max_recv_speed: None, max_redirect: Count::Finite(50), max_send_speed: None, negotiate: false, netrc: false, netrc_file: None, netrc_optional: false, no_proxy: None, ntlm: false, path_as_is: false, pinned_pub_key: None, proxy: None, resolves: vec![], ssl_no_revoke: false, timeout: Duration::from_secs(300), unix_socket: None, user: None, user_agent: None, verbosity: None, } } } hurl-7.1.0/src/http/param.rs000064400000000000000000000022451046102023000140220ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use core::fmt; /// A key/value pair used for query params, form params and multipart-form params. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Param { pub name: String, pub value: String, } impl Param { /// Creates a new param pair. pub fn new(name: &str, value: &str) -> Param { Param { name: name.to_string(), value: value.to_string(), } } } impl fmt::Display for Param { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: {}", self.name, self.value) } } hurl-7.1.0/src/http/request.rs000064400000000000000000000134461046102023000144170ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::fmt; use super::header::{HeaderVec, COOKIE}; use super::request_cookie::RequestCookie; use super::url::Url; /// Represents a runtime HTTP request. /// This is a real request, that has been executed by our HTTP client. /// It's different from `crate::http::RequestSpec` which is the request asked to be executed by our /// user. For instance, in the request spec, headers implicitly added by curl are not present, while /// they will be present in the [`Request`] instances. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Request { /// Absolute URL. pub url: Url, /// Method. pub method: String, /// List of HTTP headers. pub headers: HeaderVec, /// Response body bytes. pub body: Vec, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] pub enum RequestedHttpVersion { /// The effective HTTP version will be chosen by libcurl #[default] Default, Http10, Http11, Http2, Http3, } impl fmt::Display for RequestedHttpVersion { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let value = match self { RequestedHttpVersion::Default => "HTTP (default)", RequestedHttpVersion::Http10 => "HTTP/1.0", RequestedHttpVersion::Http11 => "HTTP/1.1", RequestedHttpVersion::Http2 => "HTTP/2", RequestedHttpVersion::Http3 => "HTTP/3", }; write!(f, "{value}") } } #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] pub enum IpResolve { /// Default, can use addresses of all IP versions that your system allows. #[default] Default, IpV4, IpV6, } impl Request { /// Creates a new request. pub fn new(method: &str, url: Url, headers: HeaderVec, body: Vec) -> Self { Request { url, method: method.to_string(), headers, body, } } /// Returns a list of request headers cookie. /// /// see pub fn cookies(&self) -> Vec { self.headers .get_all(COOKIE) .iter() .flat_map(|h| parse_cookies(h.value.as_str().trim())) .collect() } } fn parse_cookies(s: &str) -> Vec { s.split(';').map(|t| parse_cookie(t.trim())).collect() } fn parse_cookie(s: &str) -> RequestCookie { match s.find('=') { Some(i) => RequestCookie { name: s.split_at(i).0.to_string(), value: s.split_at(i + 1).1.to_string(), }, None => RequestCookie { name: s.to_string(), value: String::new(), }, } } #[cfg(test)] mod tests { use super::*; use crate::http::{Header, RequestCookie}; fn hello_request() -> Request { let mut headers = HeaderVec::new(); headers.push(Header::new("Host", "localhost:8000")); headers.push(Header::new("Accept", "*/*")); headers.push(Header::new("User-Agent", "hurl/1.0")); headers.push(Header::new("content-type", "application/json")); let url = "http://localhost:8000/hello".parse().unwrap(); Request::new("GET", url, headers, vec![]) } fn query_string_request() -> Request { let url = "http://localhost:8000/querystring-params?param1=value1¶m2=¶m3=a%3Db¶m4=1%2C2%2C3".parse().unwrap(); Request::new("GET", url, HeaderVec::new(), vec![]) } fn cookies_request() -> Request { let mut headers = HeaderVec::new(); headers.push(Header::new("Cookie", "cookie1=value1; cookie2=value2")); let url = "http://localhost:8000/cookies".parse().unwrap(); Request::new("GET", url, headers, vec![]) } #[test] fn test_content_type() { assert_eq!( hello_request().headers.content_type(), Some("application/json") ); assert_eq!(query_string_request().headers.content_type(), None); assert_eq!(cookies_request().headers.content_type(), None); } #[test] fn test_cookies() { assert!(hello_request().cookies().is_empty()); assert_eq!( cookies_request().cookies(), vec![ RequestCookie { name: "cookie1".to_string(), value: "value1".to_string(), }, RequestCookie { name: "cookie2".to_string(), value: "value2".to_string(), }, ] ); } #[test] fn test_parse_cookies() { assert_eq!( parse_cookies("cookie1=value1; cookie2=value2"), vec![ RequestCookie { name: "cookie1".to_string(), value: "value1".to_string(), }, RequestCookie { name: "cookie2".to_string(), value: "value2".to_string(), }, ] ); } #[test] fn test_parse_cookie() { assert_eq!( parse_cookie("cookie1=value1"), RequestCookie { name: "cookie1".to_string(), value: "value1".to_string(), }, ); } } hurl-7.1.0/src/http/request_cookie.rs000064400000000000000000000016221046102023000157410ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use core::fmt; #[derive(Clone, Debug, PartialEq, Eq)] pub struct RequestCookie { pub name: String, pub value: String, } impl fmt::Display for RequestCookie { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}={}", self.name, self.value) } } hurl-7.1.0/src/http/request_spec.rs000064400000000000000000000063561046102023000154330ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use core::fmt; use super::header::HeaderVec; use super::param::Param; use super::request_cookie::RequestCookie; use super::url::Url; /// Represents the HTTP request asked to be executed by our user (different from the runtime /// executed HTTP request [`crate::http::Request`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct RequestSpec { pub method: Method, pub url: Url, pub headers: HeaderVec, pub querystring: Vec, pub form: Vec, pub multipart: Vec, pub cookies: Vec, pub body: Body, /// This is the implicit content type of the request: this content type is implicitly set when /// the request use a "typed" body: form, JSON, multipart, multiline string with hint. This /// implicit content type can be different from the user provided one through the `headers` /// field. pub implicit_content_type: Option, } impl Default for RequestSpec { fn default() -> Self { RequestSpec { method: Method("GET".to_string()), url: Url::default(), headers: HeaderVec::new(), querystring: vec![], form: vec![], multipart: vec![], cookies: vec![], body: Body::Binary(vec![]), implicit_content_type: None, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Method(pub String); #[derive(Clone, Debug, PartialEq, Eq)] pub enum MultipartParam { Param(Param), FileParam(FileParam), } #[derive(Clone, Debug, PartialEq, Eq)] pub struct FileParam { pub name: String, pub filename: String, pub data: Vec, pub content_type: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum Body { Text(String), Binary(Vec), File(Vec, String), } impl Body { pub fn bytes(&self) -> Vec { match self { Body::Text(s) => s.as_bytes().to_vec(), Body::Binary(bs) => bs.clone(), Body::File(bs, _) => bs.clone(), } } } impl fmt::Display for Method { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) } } impl fmt::Display for MultipartParam { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { MultipartParam::Param(param) => write!(f, "{param}"), MultipartParam::FileParam(param) => write!(f, "{param}"), } } } impl fmt::Display for FileParam { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}: file,{}; {}", self.name, self.filename, self.content_type ) } } hurl-7.1.0/src/http/response.rs000064400000000000000000000060321046102023000145560ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::fmt; use std::time::Duration; use super::certificate::Certificate; use super::header::HeaderVec; use super::ip::IpAddr; use super::url::Url; /// Represents a runtime HTTP response. /// This is a real response, that has been executed by our HTTP client. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Response { pub version: HttpVersion, pub status: u32, pub headers: HeaderVec, pub body: Vec, pub duration: Duration, pub url: Url, /// The end-user certificate, in the response certificate chain pub certificate: Option, pub ip_addr: IpAddr, } impl Response { /// Creates a new HTTP response #[allow(clippy::too_many_arguments)] pub fn new( version: HttpVersion, status: u32, headers: HeaderVec, body: Vec, duration: Duration, url: Url, certificate: Option, ip_addr: IpAddr, ) -> Self { Response { version, status, headers, body, duration, url, certificate, ip_addr, } } } /// Represents the HTTP version of a HTTP transaction. /// See #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum HttpVersion { Http10, Http11, Http2, Http3, } impl fmt::Display for HttpVersion { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let value = match self { HttpVersion::Http10 => "HTTP/1.0", HttpVersion::Http11 => "HTTP/1.1", HttpVersion::Http2 => "HTTP/2", HttpVersion::Http3 => "HTTP/3", }; write!(f, "{value}") } } #[cfg(test)] mod tests { use super::*; use crate::http::Header; #[test] fn get_header_values() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Length", "12")); let response = Response { version: HttpVersion::Http10, status: 200, headers, body: vec![], duration: Default::default(), url: "http://localhost".parse().unwrap(), certificate: None, ip_addr: Default::default(), }; assert_eq!(response.headers.values("Content-Length"), vec!["12"]); assert!(response.headers.values("Unknown").is_empty()); } } hurl-7.1.0/src/http/response_cookie.rs000064400000000000000000000321401046102023000161060ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! This module defines an HTTP response cookie, namely the cookie returned from the response //! `Set-Cookie` header. use super::header::SET_COOKIE; use super::response::Response; /// Cookie returned from HTTP Response /// It contains arbitrary attributes. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResponseCookie { pub name: String, pub value: String, pub attributes: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CookieAttribute { pub name: String, pub value: Option, } /// See const EXPIRES: &str = "Expires"; /// See const DOMAIN: &str = "Domain"; /// See const HTTP_ONLY: &str = "HttpOnly"; /// See const MAX_AGE: &str = "Max-Age"; /// See const PATH: &str = "Path"; /// See const SAME_SITE: &str = "SameSite"; /// See const SECURE: &str = "Secure"; impl ResponseCookie { /// Parses value from Set-Cookie header into a `ResponseCookie`. /// /// See pub fn parse(s: &str) -> Option { if let Some(index) = s.find('=') { let (name, remaining) = s.split_at(index); let mut tokens: Vec<&str> = remaining[1..].split(';').collect(); let value = tokens.remove(0); let attributes: Vec = tokens .iter() .filter_map(|&s2| CookieAttribute::parse(s2.to_string())) .collect(); Some(ResponseCookie { name: name.to_string(), value: value.to_string(), attributes, }) } else { None } } /// Returns the optional Expires attribute as `String` type. pub fn expires(&self) -> Option { self.attr_as_str(EXPIRES) } /// Returns the optional Max-Age attribute as `i64` type. /// /// If the value is not a valid integer, the attribute is simply ignored pub fn max_age(&self) -> Option { self.attr_as_i64(MAX_AGE) } /// Returns the optional Domain attribute as `String` type. pub fn domain(&self) -> Option { self.attr_as_str(DOMAIN) } /// Returns the optional Path attribute as `String` type. pub fn path(&self) -> Option { self.attr_as_str(PATH) } /// Return true if the Secure attribute is present. pub fn has_secure(&self) -> bool { self.attr_as_bool(SECURE) } /// Return true if the HttpOnly attribute is present. pub fn has_httponly(&self) -> bool { self.attr_as_bool(HTTP_ONLY) } /// Returns the optional SameSite attribute as `String` type. pub fn samesite(&self) -> Option { self.attr_as_str(SAME_SITE) } /// Converts a cookie attribute value named `name` into a string. fn attr_as_str(&self, name: &str) -> Option { for attr in &self.attributes { if attr.name.to_lowercase() == name.to_lowercase() { return attr.value.clone(); } } None } /// Converts a cookie attribute value named `name` into a boolean. fn attr_as_bool(&self, name: &str) -> bool { for attr in &self.attributes { if attr.name.to_lowercase() == name.to_lowercase() && attr.value.is_none() { return true; } } false } /// Converts a cookie attribute value named `name` into an integer. fn attr_as_i64(&self, name: &str) -> Option { for attr in &self.attributes { if attr.name.to_lowercase() == name.to_lowercase() { if let Some(v) = &attr.value { if let Ok(v2) = v.as_str().parse::() { return Some(v2); } } } } None } } impl CookieAttribute { fn parse(s: String) -> Option { if s.is_empty() { None } else { let tokens: Vec<&str> = s.split('=').collect(); Some(CookieAttribute { name: tokens.first().unwrap().to_string().trim().to_string(), value: tokens.get(1).map(|e| e.to_string()), }) } } } impl Response { pub fn cookies(&self) -> Vec { self.headers .get_all(SET_COOKIE) .iter() .filter_map(|h| ResponseCookie::parse(&h.value)) .collect() } /// Returns optional cookies from response. pub fn get_cookie(&self, name: &str) -> Option { self.cookies() .into_iter() .find(|cookie| cookie.name == name) } } #[cfg(test)] pub mod tests { use super::*; #[test] fn test_parse_cookie_attribute() { assert_eq!( CookieAttribute::parse("Expires=Wed, 21 Oct 2015 07:28:00 GMT".to_string()).unwrap(), CookieAttribute { name: "Expires".to_string(), value: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()) } ); assert_eq!( CookieAttribute::parse("HttpOnly".to_string()).unwrap(), CookieAttribute { name: "HttpOnly".to_string(), value: None } ); assert_eq!( CookieAttribute::parse("httponly".to_string()).unwrap(), CookieAttribute { name: "httponly".to_string(), value: None } ); assert_eq!(CookieAttribute::parse(String::new()), None); } #[test] fn test_session_cookie() { let cookie = ResponseCookie { name: "sessionId".to_string(), value: "38afes7a8".to_string(), attributes: vec![], }; assert_eq!( ResponseCookie::parse("sessionId=38afes7a8").unwrap(), cookie ); assert_eq!(cookie.expires(), None); assert_eq!(cookie.max_age(), None); assert_eq!(cookie.domain(), None); assert_eq!(cookie.path(), None); assert!(!cookie.has_secure()); assert!(!cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } #[test] fn test_permanent_cookie() { let cookie = ResponseCookie { name: "id".to_string(), value: "a3fWa".to_string(), attributes: vec![CookieAttribute { name: "Expires".to_string(), value: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()), }], }; assert_eq!( ResponseCookie::parse("id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT").unwrap(), cookie ); assert_eq!( cookie.expires(), Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()) ); assert_eq!(cookie.max_age(), None); assert_eq!(cookie.domain(), None); assert_eq!(cookie.path(), None); assert!(!cookie.has_secure()); assert!(!cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } #[test] fn test_permanent2_cookie() { let cookie = ResponseCookie { name: "id".to_string(), value: "a3fWa".to_string(), attributes: vec![CookieAttribute { name: "Max-Age".to_string(), value: Some("2592000".to_string()), }], }; assert_eq!( ResponseCookie::parse("id=a3fWa; Max-Age=2592000").unwrap(), cookie ); assert_eq!(cookie.expires(), None); assert_eq!(cookie.max_age(), Some(2592000)); assert_eq!(cookie.domain(), None); assert_eq!(cookie.path(), None); assert!(!cookie.has_secure()); assert!(!cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } #[test] fn test_lsid_cookie() { let cookie = ResponseCookie { name: "LSID".to_string(), value: "DQAAAK…Eaem_vYg".to_string(), attributes: vec![ CookieAttribute { name: "Path".to_string(), value: Some("/accounts".to_string()), }, CookieAttribute { name: "Expires".to_string(), value: Some("Wed, 13 Jan 2021 22:23:01 GMT".to_string()), }, CookieAttribute { name: "Secure".to_string(), value: None, }, CookieAttribute { name: "HttpOnly".to_string(), value: None, }, ], }; assert_eq!( ResponseCookie::parse("LSID=DQAAAK…Eaem_vYg; Path=/accounts; Expires=Wed, 13 Jan 2021 22:23:01 GMT; Secure; HttpOnly").unwrap(), cookie ); assert_eq!( cookie.expires(), Some("Wed, 13 Jan 2021 22:23:01 GMT".to_string()) ); assert_eq!(cookie.max_age(), None); assert_eq!(cookie.domain(), None); assert_eq!(cookie.path(), Some("/accounts".to_string())); assert!(cookie.has_secure()); assert!(cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } #[test] fn test_hsid_cookie() { let cookie = ResponseCookie { name: "HSID".to_string(), value: "AYQEVn…DKrdst".to_string(), attributes: vec![ CookieAttribute { name: "Domain".to_string(), value: Some(".foo.com".to_string()), }, CookieAttribute { name: "Path".to_string(), value: Some("/".to_string()), }, CookieAttribute { name: "Expires".to_string(), value: Some("Wed, 13 Jan 2021 22:23:01 GMT".to_string()), }, CookieAttribute { name: "HttpOnly".to_string(), value: None, }, ], }; assert_eq!( ResponseCookie::parse("HSID=AYQEVn…DKrdst; Domain=.foo.com; Path=/; Expires=Wed, 13 Jan 2021 22:23:01 GMT; HttpOnly").unwrap(), cookie ); assert_eq!( cookie.expires(), Some("Wed, 13 Jan 2021 22:23:01 GMT".to_string()) ); assert_eq!(cookie.max_age(), None); assert_eq!(cookie.domain(), Some(".foo.com".to_string())); assert_eq!(cookie.path(), Some("/".to_string())); assert!(!cookie.has_secure()); assert!(cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } #[test] fn test_trailing_semicolon() { assert_eq!( ResponseCookie::parse("xx=yy;").unwrap(), ResponseCookie { name: "xx".to_string(), value: "yy".to_string(), attributes: vec![] } ); } #[test] fn test_invalid_cookie() { assert_eq!(ResponseCookie::parse("xx"), None); } #[test] fn test_cookie_with_invalid_attributes() { let cookie = ResponseCookie { name: "id".to_string(), value: "a3fWa".to_string(), attributes: vec![ CookieAttribute { name: "Secure".to_string(), value: Some("0".to_string()), }, CookieAttribute { name: "Max-Age".to_string(), value: Some(String::new()), }, ], }; assert_eq!( ResponseCookie::parse("id=a3fWa; Secure=0; Max-Age=").unwrap(), cookie ); assert_eq!(cookie.expires(), None); assert_eq!(cookie.max_age(), None); assert_eq!(cookie.domain(), None); assert_eq!(cookie.path(), None); assert!(!cookie.has_secure()); assert!(!cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } } hurl-7.1.0/src/http/response_debug.rs000064400000000000000000000045661046102023000157360ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::text::{Format, Style, StyledString}; use crate::util::logger::Logger; use super::debug; use super::mimetype; use super::response::Response; impl Response { /// Log a response body as text if possible, or a slice of body bytes. pub fn log_body(&self, debug: bool, logger: &mut Logger) { // We try to decode the HTTP body as text if the response has a text kind content type. // If it ok, we print each line of the body in debug format. Otherwise, we // print the body first 64 bytes. if let Some(content_type) = self.headers.content_type() { if !mimetype::is_kind_of_text(content_type) { debug::log_bytes(&self.body, 64, debug, logger); return; } } match self.text() { Ok(text) => debug::log_text(&text, debug, logger), Err(_) => debug::log_bytes(&self.body, 64, debug, logger), } } pub fn log_info_all(&self, logger: &mut Logger) { let status_line = self.get_status_line_headers(logger.color); logger.info(&status_line); self.log_body(false, logger); logger.info(""); } /// Returns status, version and HTTP headers from this HTTP response. pub fn get_status_line_headers(&self, color: bool) -> String { let mut s = StyledString::new(); s.push_with( &format!("{} {}\n", self.version, self.status), Style::new().green().bold(), ); for header in &self.headers { s.push_with(&header.name, Style::new().cyan().bold()); s.push(&format!(": {}\n", header.value)); } if color { s.to_string(Format::Ansi) } else { s.to_string(Format::Plain) } } } hurl-7.1.0/src/http/response_decoding.rs000064400000000000000000000325111046102023000164130ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ /// /// Decompresses body response /// using the Content-Encoding response header /// /// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding use std::io::prelude::*; use super::error::HttpError; use super::mimetype; use super::response::Response; #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum ContentEncoding { /// A format using the Brotli algorithm structure (defined in RFC 7932). Brotli, /// A format using the Lempel-Ziv coding (LZ77), with a 32-bit CRC. Gzip, /// Using the zlib structure (defined in RFC 1950) with the deflate compression algorithm. Deflate, /// No encoding. Identity, } impl ContentEncoding { /// Returns an encoding from an HTTP header value `s`. pub fn parse(s: &str) -> Result { match s { "br" => Ok(ContentEncoding::Brotli), "gzip" => Ok(ContentEncoding::Gzip), "deflate" => Ok(ContentEncoding::Deflate), "identity" => Ok(ContentEncoding::Identity), v => Err(HttpError::UnsupportedContentEncoding { description: v.to_string(), }), } } /// Decompresses `data` bytes. pub fn decode(&self, data: &[u8]) -> Result, HttpError> { match self { ContentEncoding::Identity => Ok(data.to_vec()), ContentEncoding::Gzip => uncompress_gzip(data), ContentEncoding::Deflate => uncompress_zlib(data), ContentEncoding::Brotli => uncompress_brotli(data), } } } impl Response { /// Returns response body as text. pub fn text(&self) -> Result { let content_encodings = self.headers.content_encoding()?; let body = if content_encodings.is_empty() { &self.body } else { &self.uncompress_body()? }; let character_encoding = self.headers.character_encoding()?; match character_encoding.decode_without_bom_handling_and_without_replacement(body) { Some(s) => Ok(s.to_string()), None => Err(HttpError::InvalidDecoding { charset: character_encoding.name().to_string(), }), } } /// Returns true if response is an HTML response. pub fn is_html(&self) -> bool { self.headers.content_type().is_some_and(mimetype::is_html) } /// Returns true if response is a JSON response. pub fn is_json(&self) -> bool { self.headers.content_type().is_some_and(mimetype::is_json) } /// Returns true if response is a XML response. pub fn is_xml(&self) -> bool { self.headers.content_type().is_some_and(mimetype::is_xml) } /// Decompresses HTTP body response. pub fn uncompress_body(&self) -> Result, HttpError> { let encodings = self.headers.content_encoding()?; let mut data = self.body.clone(); for encoding in &encodings { data = encoding.decode(&data)?; } Ok(data) } } /// Decompresses Brotli compressed `data`. fn uncompress_brotli(data: &[u8]) -> Result, HttpError> { let buffer_size = 4096; let mut reader = brotli::Decompressor::new(data, buffer_size); let mut buf = Vec::new(); match reader.read_to_end(&mut buf) { Ok(_) => Ok(buf), Err(_) => Err(HttpError::CouldNotUncompressResponse { description: "brotli".to_string(), }), } } /// Decompresses GZip compressed `data`. fn uncompress_gzip(data: &[u8]) -> Result, HttpError> { let mut decoder = match libflate::gzip::Decoder::new(data) { Ok(v) => v, Err(_) => { return Err(HttpError::CouldNotUncompressResponse { description: "gzip".to_string(), }) } }; let mut buf = Vec::new(); match decoder.read_to_end(&mut buf) { Ok(_) => Ok(buf), Err(_) => Err(HttpError::CouldNotUncompressResponse { description: "gzip".to_string(), }), } } /// Decompresses Zlib compressed `data`. fn uncompress_zlib(data: &[u8]) -> Result, HttpError> { let mut decoder = match libflate::zlib::Decoder::new(data) { Ok(v) => v, Err(_) => { return Err(HttpError::CouldNotUncompressResponse { description: "zlib".to_string(), }) } }; let mut buf = Vec::new(); match decoder.read_to_end(&mut buf) { Ok(_) => Ok(buf), Err(_) => Err(HttpError::CouldNotUncompressResponse { description: "zlib".to_string(), }), } } #[cfg(test)] pub mod tests { use super::*; use crate::http::{Header, HeaderVec, HttpVersion, Response}; fn default_response() -> Response { Response { version: HttpVersion::Http10, status: 200, headers: HeaderVec::new(), body: vec![], duration: Default::default(), url: "http://localhost".parse().unwrap(), certificate: None, ip_addr: Default::default(), } } #[test] fn test_parse_content_encoding() { assert_eq!( ContentEncoding::parse("br").unwrap(), ContentEncoding::Brotli ); assert_eq!( ContentEncoding::parse("xx").err().unwrap(), HttpError::UnsupportedContentEncoding { description: "xx".to_string() } ); } #[test] fn test_content_encoding() { let response = default_response(); assert_eq!(response.headers.content_encoding().unwrap(), vec![]); let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "xx")); let response = Response { headers, ..default_response() }; assert_eq!( response.headers.content_encoding().err().unwrap(), HttpError::UnsupportedContentEncoding { description: "xx".to_string() } ); let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "br")); let response = Response { headers, ..default_response() }; assert_eq!( response.headers.content_encoding().unwrap(), vec![ContentEncoding::Brotli] ); } #[test] fn test_multiple_content_encoding() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "br, identity")); let response = Response { headers, ..default_response() }; assert_eq!( response.headers.content_encoding().unwrap(), vec![ContentEncoding::Brotli, ContentEncoding::Identity] ); } #[test] fn test_uncompress_body() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "br")); let response = Response { headers, body: vec![ 0x21, 0x2c, 0x00, 0x04, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x03, ], ..default_response() }; assert_eq!(response.uncompress_body().unwrap(), b"Hello World!"); let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "br, identity")); let response = Response { headers, body: vec![ 0x21, 0x2c, 0x00, 0x04, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x03, ], ..default_response() }; assert_eq!(response.uncompress_body().unwrap(), b"Hello World!"); let response = Response { body: b"Hello World!".to_vec(), ..default_response() }; assert_eq!(response.uncompress_body().unwrap(), b"Hello World!"); } #[test] fn test_uncompress_brotli() { let data = [ 0x21, 0x2c, 0x00, 0x04, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x03, ]; assert_eq!(uncompress_brotli(&data[..]).unwrap(), b"Hello World!"); } #[test] fn test_uncompress_gzip() { let data = [ 0x1f, 0x8b, 0x08, 0x08, 0xa7, 0x52, 0x85, 0x5f, 0x00, 0x03, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x74, 0x78, 0x74, 0x00, 0xf3, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x08, 0xcf, 0x2f, 0xca, 0x49, 0x51, 0x04, 0x00, 0xa3, 0x1c, 0x29, 0x1c, 0x0c, 0x00, 0x00, 0x00, ]; assert_eq!(uncompress_gzip(&data[..]).unwrap(), b"Hello World!"); } #[test] fn test_uncompress_zlib() { let data = [ 0x78, 0x9c, 0xf3, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x08, 0xcf, 0x2f, 0xca, 0x49, 0x51, 0x04, 0x00, 0x1c, 0x49, 0x04, 0x3e, ]; assert_eq!(uncompress_zlib(&data[..]).unwrap(), b"Hello World!"); } #[test] fn test_uncompress_error() { let data = [0x21]; assert_eq!( uncompress_brotli(&data[..]).err().unwrap(), HttpError::CouldNotUncompressResponse { description: "brotli".to_string() } ); assert_eq!( uncompress_gzip(&data[..]).err().unwrap(), HttpError::CouldNotUncompressResponse { description: "gzip".to_string() } ); } fn hello_response() -> Response { Response { body: b"Hello World!".to_vec(), ..default_response() } } fn utf8_encoding_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "text/plain; charset=utf-8")); Response { headers, body: vec![0x63, 0x61, 0x66, 0xc3, 0xa9], ..default_response() } } fn latin1_encoding_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new( "Content-Type", "text/plain; charset=ISO-8859-1", )); Response { headers, body: vec![0x63, 0x61, 0x66, 0xe9], ..default_response() } } #[test] pub fn test_content_type() { assert_eq!(hello_response().headers.content_type(), None); assert_eq!( utf8_encoding_response().headers.content_type(), Some("text/plain; charset=utf-8") ); assert_eq!( latin1_encoding_response().headers.content_type(), Some("text/plain; charset=ISO-8859-1") ); } #[test] pub fn test_character_encoding() { assert_eq!( hello_response().headers.character_encoding().unwrap(), encoding_rs::UTF_8 ); assert_eq!( utf8_encoding_response() .headers .character_encoding() .unwrap(), encoding_rs::UTF_8 ); assert_eq!( latin1_encoding_response() .headers .character_encoding() .unwrap(), encoding_rs::WINDOWS_1252 ); } #[test] pub fn test_text() { assert_eq!(hello_response().text().unwrap(), "Hello World!".to_string()); assert_eq!(utf8_encoding_response().text().unwrap(), "café".to_string()); assert_eq!( latin1_encoding_response().text().unwrap(), "café".to_string() ); } #[test] pub fn test_invalid_charset() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "test/plain; charset=xxx")); assert_eq!( Response { headers, body: b"Hello World!".to_vec(), ..default_response() } .headers .character_encoding() .err() .unwrap(), HttpError::InvalidCharset { charset: "xxx".to_string() } ); } #[test] pub fn test_invalid_decoding() { assert_eq!( Response { body: vec![0x63, 0x61, 0x66, 0xe9], ..default_response() } .text() .err() .unwrap(), HttpError::InvalidDecoding { charset: "UTF-8".to_string() } ); let mut headers = HeaderVec::new(); headers.push(Header::new( "Content-Type", "text/plain; charset=ISO-8859-1", )); assert_eq!( Response { headers, body: vec![0x63, 0x61, 0x66, 0xc3, 0xa9], ..default_response() } .text() .unwrap(), "café".to_string() ); } } hurl-7.1.0/src/http/tests/mod.rs000064400000000000000000000113631046102023000146440ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! Some [`Request`]/[`Response`] used by tests. use std::str::FromStr; use crate::http::{ Header, HeaderVec, HttpVersion, Method, Param, RequestCookie, RequestSpec, Response, Url, }; fn default_response() -> Response { Response { version: HttpVersion::Http10, status: 200, headers: HeaderVec::new(), body: vec![], duration: Default::default(), url: Url::from_str("http://localhost").unwrap(), certificate: None, ip_addr: Default::default(), } } pub fn hello_http_request() -> RequestSpec { RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost:8000/hello").unwrap(), ..Default::default() } } pub fn json_http_response() -> Response { Response { body: String::into_bytes( r#" { "success":false, "errors": [ { "id": "error1"}, {"id": "error2"} ], "duration": 1.5 } "# .to_string(), ), ..default_response() } } pub fn xml_two_users_http_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "text/html; charset=utf-8")); headers.push(Header::new("Content-Length", "12")); Response { headers, body: String::into_bytes( r#" Bob Bill "# .to_string(), ), ..default_response() } } pub fn xml_three_users_http_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "text/html; charset=utf-8")); headers.push(Header::new("Content-Length", "12")); Response { headers, body: String::into_bytes( r#" Bob Bill Bruce "# .to_string(), ), ..default_response() } } pub fn hello_http_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "text/html; charset=utf-8")); headers.push(Header::new("Content-Length", "12")); Response { headers, body: String::into_bytes(String::from("Hello World!")), ..default_response() } } pub fn bytes_http_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "application/octet-stream")); headers.push(Header::new("Content-Length", "1")); Response { headers, body: vec![255], ..default_response() } } pub fn html_http_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "application/octet-stream")); Response { headers, body: String::into_bytes(String::from( "
", )), ..default_response() } } pub fn query_http_request() -> RequestSpec { RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost:8000/querystring-params").unwrap(), querystring: vec![ Param { name: String::from("param1"), value: String::from("value1"), }, Param { name: String::from("param2"), value: String::from("a b"), }, ], ..Default::default() } } pub fn custom_http_request() -> RequestSpec { let mut headers = HeaderVec::new(); headers.push(Header::new("User-Agent", "iPhone")); headers.push(Header::new("Foo", "Bar")); RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost/custom").unwrap(), headers, cookies: vec![ RequestCookie { name: String::from("theme"), value: String::from("light"), }, RequestCookie { name: String::from("sessionToken"), value: String::from("abc123"), }, ], ..Default::default() } } hurl-7.1.0/src/http/timings.rs000064400000000000000000000052431046102023000143750ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::time::Duration; use chrono::{DateTime, Utc}; use curl::easy::Easy; use super::easy_ext; /// Timing information for an HTTP transfer (see ). // See [`easy_ext::namelookup_time_t`], [`easy_ext::connect_time_t`], [`easy_ext::app_connect_time_t`], // [`easy_ext::pre_transfer_time_t`], [`easy_ext::start_transfer_time_t`] and [`easy_ext::total_time_t`] // for fields definition. #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct Timings { pub begin_call: DateTime, pub end_call: DateTime, pub name_lookup: Duration, pub connect: Duration, pub app_connect: Duration, pub pre_transfer: Duration, pub start_transfer: Duration, pub total: Duration, } impl Timings { pub fn new(easy: &mut Easy, begin_call: DateTime, end_call: DateTime) -> Self { // We try the *_t timing function of libcurl (available for libcurl >= 7.61.0) // returning timing in nanoseconds, or fallback to timing function returning seconds // if *_t are not available. let name_lookup = easy_ext::namelookup_time_t(easy) .or(easy.namelookup_time()) .unwrap_or_default(); let connect = easy_ext::connect_time_t(easy) .or(easy.connect_time()) .unwrap_or_default(); let app_connect = easy_ext::appconnect_time_t(easy) .or(easy.appconnect_time()) .unwrap_or_default(); let pre_transfer = easy_ext::pretransfer_time_t(easy) .or(easy.pretransfer_time()) .unwrap_or_default(); let start_transfer = easy_ext::starttransfer_time_t(easy) .or(easy.starttransfer_time()) .unwrap_or_default(); let total = easy_ext::total_time_t(easy) .or(easy.total_time()) .unwrap_or_default(); Timings { begin_call, end_call, name_lookup, connect, app_connect, pre_transfer, start_transfer, total, } } } hurl-7.1.0/src/http/timings_debug.rs000064400000000000000000000027701046102023000155450ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::util::logger::Logger; use super::Timings; impl Timings { /// Logs the response timings information. pub fn log(&self, logger: &mut Logger) { logger.debug_important("Timings:"); logger.debug(&format!("begin: {}", self.begin_call)); logger.debug(&format!("end: {}", self.end_call)); logger.debug(&format!("namelookup: {} µs", self.name_lookup.as_micros())); logger.debug(&format!("connect: {} µs", self.connect.as_micros())); logger.debug(&format!("app_connect: {} µs", self.app_connect.as_micros())); logger.debug(&format!( "pre_transfer: {} µs", self.pre_transfer.as_micros() )); logger.debug(&format!( "start_transfer: {} µs", self.start_transfer.as_micros() )); logger.debug(&format!("total: {} µs", self.total.as_micros())); } } hurl-7.1.0/src/http/url.rs000064400000000000000000000210751046102023000135260ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use core::str; use std::fmt; use std::str::FromStr; use regex::Regex; use super::error::HttpError; use super::param::Param; /// Represents errors for the URL module. #[derive(Clone, Eq, PartialEq, Debug)] pub struct UrlError { pub url: String, pub reason: String, } impl UrlError { /// Creates a new error. fn new(url: &str, reason: &str) -> Self { UrlError { url: url.to_string(), reason: reason.to_string(), } } } /// A parsed URL. #[derive(Clone, Eq, PartialEq, Debug)] pub struct Url { /// The input url from the user raw: String, /// A structured URL (implementation). inner: url::Url, } impl Default for Url { fn default() -> Self { Url::from_str("https://localhost").unwrap() } } impl Url { pub fn raw(&self) -> String { self.raw.clone() } /// Returns a list of query parameters (values are URL decoded). pub fn query_params(&self) -> Vec { self.inner .query_pairs() .map(|(k, v)| Param::new(&k, &v)) .collect() } /// Returns the parsed representation of the host for this URL. /// See also the `host_str` method. /// /// # Examples /// /// ``` /// use std::str::FromStr; /// use hurl::http::Url; /// /// let url = Url::from_str("https://127.0.0.1/index.html").unwrap(); /// assert_eq!(url.host(), "127.0.0.1".to_string()); /// /// let url = Url::from_str("http://foo.com/index.html").unwrap(); /// assert_eq!(url.host(), "foo.com".to_string()); /// /// ``` pub fn host(&self) -> String { self.inner .host() .expect("HTTP and HTTPS URL must have a domain") .to_string() } /// Returns the scheme of this URL, lower-cased, as an ASCII string without the ':' delimiter. /// /// # Examples /// /// ``` /// use hurl::http::Url; /// /// let url: Url = "http://toto.com/foo".parse().unwrap(); /// assert_eq!(url.scheme(), "http"); /// ``` pub fn scheme(&self) -> &str { self.inner.scheme() } /// Returns the port of this URL. /// /// # Examples /// /// ``` /// use std::str::FromStr; /// use hurl::http::Url; /// /// let url = Url::from_str("https://bar.com:8081/foo").unwrap(); /// assert_eq!(url.port(), Some(8081)); /// /// let url = Url::from_str("https://baz.com").unwrap(); /// assert_eq!(url.port(), Some(443)); /// ``` pub fn port(&self) -> Option { self.inner.port().or_else(|| match self.scheme() { "http" | "ws" => Some(80), "https" | "wss" => Some(443), "ftp" => Some(21), _ => None, }) } pub fn domain(&self) -> Option<&str> { self.inner.domain() } pub fn path(&self) -> &str { self.inner.path() } /// Parse a string `input` as an URL, with this URL as the base URL. pub fn join(&self, input: &str) -> Result { let new_inner = self.inner.join(input); let new_inner = match new_inner { Ok(u) => u, Err(_) => { let error = UrlError::new( self.inner.as_str(), &format!("Can not use relative path '{input}'"), ); return Err(error); } }; new_inner.as_str().parse() } } impl FromStr for Url { type Err = UrlError; /// Parses an absolute URL from a string. fn from_str(value: &str) -> Result { // We try the happy path first: for the moment we're only supporting HTTP/HTTPS scheme. // Other scheme will go into `try_scheme` which uses regex and can be less performant in // stress tests usages than this simple `starts_with`. if value.starts_with("https://") || value.starts_with("http://") { let raw = value.to_string(); let inner = url::Url::parse(&raw).map_err(|e| UrlError::new(value, &e.to_string()))?; Ok(Url { raw, inner }) } else { match try_scheme(value) { Some(_) => Err(UrlError::new( value, "Only and schemes are supported", )), None => Err(UrlError::new( value, "Missing scheme or ", )), } } } } /// Extracting scheme from `url` /// /// The parse method from the url crate does not seem to parse url without scheme /// For example, "localhost:8000" is parsed with its scheme set to "localhost" /// fn try_scheme(url: &str) -> Option { let re = Regex::new("^([a-z]+://).*").unwrap(); if let Some(caps) = re.captures(url) { let scheme = &caps[1]; Some(scheme.to_string()) } else { None } } impl fmt::Display for Url { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.inner) } } impl From for HttpError { fn from(error: UrlError) -> Self { HttpError::InvalidUrl(error.url, error.reason) } } #[cfg(test)] mod tests { use std::str::FromStr; use super::{try_scheme, Url, UrlError}; use crate::http::Param; #[test] fn parse_url_ok() { let urls = [ "http://localhost:8000/hello", "http://localhost:8000/querystring-params?param1=value1¶m2=¶m3=a%3Db¶m4=1%2C2%2C3", "http://localhost:8000/cookies", "http://localhost", "https://localhost:8000", "http://localhost:8000/path-as-is/../resource" ]; for url in urls { assert!(Url::from_str(url).is_ok()); } } #[test] fn query_params() { let url: Url = "http://localhost:8000/hello".parse().unwrap(); assert_eq!(url.query_params(), vec![]); let url: Url = "http://localhost:8000/querystring-params?param1=value1¶m2=¶m3=a%3Db¶m4=1%2C2%2C3".parse().unwrap(); assert_eq!( url.query_params(), vec![ Param::new("param1", "value1"), Param::new("param2", ""), Param::new("param3", "a=b"), Param::new("param4", "1,2,3"), ] ); } #[test] fn test_join() { let base: Url = "http://example.net/foo/index.html".parse().unwrap(); // Test join with absolute assert_eq!( base.join("http://bar.com/redirected").unwrap(), "http://bar.com/redirected".parse().unwrap() ); // Test join with relative assert_eq!( base.join("/redirected").unwrap(), "http://example.net/redirected".parse().unwrap() ); assert_eq!( base.join("../bar/index.html").unwrap(), "http://example.net/bar/index.html".parse().unwrap() ); // Scheme relative URL assert_eq!( base.join("//example.org/baz/index.html").unwrap(), "http://example.org/baz/index.html".parse().unwrap() ); } #[test] fn test_parsing_error() { assert_eq!( Url::from_str("localhost:8000").err().unwrap(), UrlError::new("localhost:8000", "Missing scheme or ") ); assert_eq!( Url::from_str("file://localhost:8000").err().unwrap(), UrlError::new( "file://localhost:8000", "Only and schemes are supported" ) ); } #[test] fn test_extract_scheme() { assert!(try_scheme("localhost:8000").is_none()); assert!(try_scheme("http1://localhost:8000").is_none()); assert!(try_scheme("://localhost:8000").is_none()); assert_eq!(try_scheme("file://data").unwrap(), "file://".to_string()); assert_eq!(try_scheme("http://data").unwrap(), "http://".to_string()); } } hurl-7.1.0/src/http/version.rs000064400000000000000000000106111046102023000144030ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::collections::HashMap; #[derive(Clone, Debug, PartialEq, Eq)] pub struct CurlVersionInfo { pub host: String, pub libraries: Vec, pub features: Vec, } /// Returns the libraries and features of libcurl. /// /// Output should be similar to `curl --version` /// - /// - pub fn libcurl_version_info() -> CurlVersionInfo { let version = curl::Version::get(); let host = version.host().to_string(); let mut libraries = vec![format!("libcurl/{}", version.version())]; if let Some(s) = version.ssl_version() { libraries.push(s.to_string()); } if let Some(s) = version.libz_version() { libraries.push(format!("zlib/{s}")); } if let Some(s) = version.brotli_version() { libraries.push(format!("brotli/{s}")); } if let Some(s) = version.zstd_version() { libraries.push(format!("zstd/{s}")); } if let Some(s) = version.ares_version() { libraries.push(format!("c-ares/{s}")); } if let Some(s) = version.libidn_version() { libraries.push(format!("libidn2/{s}")); } if let Some(s) = version.iconv_version_num() { if s != 0 { libraries.push(format!("iconv/{s}")); } } if let Some(s) = version.libssh_version() { libraries.push(s.to_string()); } if let Some(s) = version.nghttp2_version() { libraries.push(format!("nghttp2/{s}")); } if let Some(s) = version.quic_version() { libraries.push(format!("quic/{s}")); } if let Some(s) = version.hyper_version() { libraries.push(format!("hyper/{s}")); } if let Some(s) = version.gsasl_version() { libraries.push(format!("libgsal/{s}")); } // FIXME: some flags are not present in crates curl-rust. // See https://github.com/alexcrichton/curl-rust/issues/464 // See https://github.com/curl/curl/blob/master/include/curl/curl.h for all curl flags // See https://github.com/alexcrichton/curl-rust/blob/main/curl-sys/lib.rs for curl-rust flags // Not defined in curl-rust: // - CURL_VERSION_GSSAPI (1<<17) // - CURL_VERSION_KERBEROS5 (1<<18) // - CURL_VERSION_PSL (1<<20) // - CURL_VERSION_HTTPS_PROXY (1<<21) // - CURL_VERSION_MULTI_SSL (1<<22) // - CURL_VERSION_THREADSAFE (1<<30) let all_features = HashMap::from([ ("AsynchDNS", version.feature_async_dns()), ("Debug", version.feature_debug()), ("IDN", version.feature_idn()), ("IPv6", version.feature_ipv6()), ("Largefile", version.feature_largefile()), ("Unicode", version.feature_unicode()), ("SSPI", version.feature_sspi()), ("SPNEGO", version.feature_spnego()), ("NTLM", version.feature_ntlm()), ("NTLM_WB", version.feature_ntlm_wb()), ("SSL", version.feature_ssl()), ("libz", version.feature_libz()), ("brotli", version.feature_brotli()), ("zstd", version.feature_zstd()), ("CharConv", version.feature_conv()), ("TLS-SRP", version.feature_tlsauth_srp()), ("HTTP2", version.feature_http2()), ("HTTP3", version.feature_http3()), ("UnixSockets", version.feature_unix_domain_socket()), ("alt-svc", version.feature_altsvc()), ("HSTS", version.feature_hsts()), ("gsasl", version.feature_gsasl()), ("GSS-Negotiate", version.feature_gss_negotiate()), ]); let mut features: Vec = vec![]; for (k, v) in all_features.iter() { if *v { features.push(k.to_string()); } } features.sort_by_key(|k| k.to_lowercase()); CurlVersionInfo { host, libraries, features, } } hurl-7.1.0/src/json/mod.rs000064400000000000000000000013211046102023000134650ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! Serialize / Deserialize a [`crate::runner::HurlResult`] to JSON. mod result; mod value; hurl-7.1.0/src/json/result.rs000064400000000000000000000355361046102023000142430ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::fs::File; use std::io; use std::io::Write; use std::path::{Path, PathBuf}; use chrono::SecondsFormat; use hurl_core::ast::SourceInfo; use hurl_core::error::{DisplaySourceError, OutputFormat}; use hurl_core::input::Input; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::http::{ Call, Certificate, Cookie, Header, HttpVersion, Param, Request, RequestCookie, Response, ResponseCookie, Timings, }; use crate::runner::{AssertResult, CaptureResult, EntryResult, HurlResult}; use crate::util::redacted::Redact; impl HurlResult { /// Serializes an [`HurlResult`] to a JSON representation. /// /// Note: `content` is passed to this method to save asserts and errors messages (with lines /// and columns). This parameter will be removed soon and the original content will be /// accessible through the [`HurlResult`] instance. /// An optional directory `response_dir` can be used to save HTTP response. /// `secrets` strings are redacted from the JSON fields. pub fn to_json( &self, content: &str, filename: &Input, response_dir: Option<&Path>, secrets: &[&str], ) -> Result { let result = HurlResultJson::from_result(self, content, filename, response_dir, secrets)?; let value = serde_json::to_value(result)?; Ok(value) } /// Checks if a JSON value can be deserialized to a `HurlResult` instance. /// This method can be used to check if the schema of the `value` is conform to /// a `HurlResult`. pub fn is_deserializable(value: &serde_json::Value) -> bool { serde_json::from_value::(value.clone()).is_ok() } } /// These structures represent the JSON schema used to serialize an [`HurlResult`] to JSON. #[derive(Deserialize, Serialize)] struct HurlResultJson { filename: String, entries: Vec, success: bool, time: u64, cookies: Vec, } #[derive(Deserialize, Serialize)] struct EntryResultJson { index: usize, line: usize, calls: Vec, captures: Vec, asserts: Vec, time: u64, curl_cmd: String, } #[derive(Deserialize, Serialize)] struct CookieJson { domain: String, include_subdomain: String, path: String, https: String, expires: String, name: String, value: String, } #[derive(Deserialize, Serialize)] struct CallJson { request: RequestJson, response: ResponseJson, timings: TimingsJson, } #[derive(Deserialize, Serialize)] struct CaptureJson { name: String, value: serde_json::Value, } #[derive(Deserialize, Serialize)] struct AssertJson { success: bool, #[serde(skip_serializing_if = "Option::is_none")] message: Option, line: usize, } #[derive(Deserialize, Serialize)] struct RequestJson { method: String, url: String, headers: Vec, cookies: Vec, query_string: Vec, } #[derive(Deserialize, Serialize)] struct ResponseJson { http_version: String, status: u32, headers: Vec, cookies: Vec, #[serde(skip_serializing_if = "Option::is_none")] certificate: Option, #[serde(skip_serializing_if = "Option::is_none")] body: Option, } #[derive(Deserialize, Serialize)] struct TimingsJson { begin_call: String, end_call: String, name_lookup: u64, connect: u64, app_connect: u64, pre_transfer: u64, start_transfer: u64, total: u64, } #[derive(Deserialize, Serialize)] struct HeaderJson { name: String, value: String, } #[derive(Deserialize, Serialize)] struct RequestCookieJson { name: String, value: String, } #[derive(Deserialize, Serialize)] struct ParamJson { name: String, value: String, } #[derive(Deserialize, Serialize)] struct ResponseCookieJson { name: String, value: String, #[serde(skip_serializing_if = "Option::is_none")] expires: Option, // FIXME: maybe max_age should be u64 #[serde(skip_serializing_if = "Option::is_none")] max_age: Option, #[serde(skip_serializing_if = "Option::is_none")] domain: Option, #[serde(skip_serializing_if = "Option::is_none")] path: Option, #[serde(skip_serializing_if = "Option::is_none")] secure: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "httponly")] http_only: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "same_site")] same_site: Option, } #[derive(Deserialize, Serialize)] struct CertificateJson { subject: String, issuer: String, start_date: String, expire_date: String, serial_number: String, } impl HurlResultJson { fn from_result( result: &HurlResult, content: &str, filename: &Input, response_dir: Option<&Path>, secrets: &[&str], ) -> Result { let entries = result .entries .iter() .map(|e| EntryResultJson::from_entry(e, content, filename, response_dir, secrets)) .collect::, _>>()?; let cookies = result .cookies .iter() .map(|c| CookieJson::from_cookie(c, secrets)) .collect::>(); Ok(HurlResultJson { filename: filename.to_string(), entries, success: result.success, time: result.duration.as_millis() as u64, cookies, }) } } impl EntryResultJson { fn from_entry( entry: &EntryResult, content: &str, filename: &Input, response_dir: Option<&Path>, secrets: &[&str], ) -> Result { let calls = entry .calls .iter() .map(|c| CallJson::from_call(c, response_dir, secrets)) .collect::, _>>()?; let captures = entry .captures .iter() .map(|c| CaptureJson::from_capture(c, secrets)) .collect::>(); let asserts = entry .asserts .iter() .map(|a| AssertJson::from_assert(a, content, filename, entry.source_info, secrets)) .collect::>(); Ok(EntryResultJson { index: entry.entry_index.get(), line: entry.source_info.start.line, calls, captures, asserts, time: entry.transfer_duration.as_millis() as u64, curl_cmd: entry.curl_cmd.to_string().redact(secrets), }) } } impl CookieJson { fn from_cookie(c: &Cookie, secrets: &[&str]) -> Self { CookieJson { domain: c.domain.clone(), include_subdomain: c.include_subdomain.clone(), path: c.path.clone(), https: c.https.clone(), expires: c.expires.clone(), name: c.name.clone(), value: c.value.redact(secrets), } } } impl CallJson { fn from_call( call: &Call, response_dir: Option<&Path>, secrets: &[&str], ) -> Result { let request = RequestJson::from_request(&call.request, secrets); let response = ResponseJson::from_response(&call.response, response_dir, secrets)?; let timings = TimingsJson::from_timings(&call.timings); Ok(CallJson { request, response, timings, }) } } impl RequestJson { fn from_request(request: &Request, secrets: &[&str]) -> Self { let headers = request .headers .iter() .map(|h| HeaderJson::from_header(h, secrets)) .collect::>(); let cookies = request .cookies() .iter() .map(|c| RequestCookieJson::from_cookie(c, secrets)) .collect::>(); let query_string = request .url .query_params() .iter() .map(|p| ParamJson::from_param(p, secrets)) .collect::>(); RequestJson { method: request.method.clone(), url: request.url.to_string().redact(secrets), headers, cookies, query_string, } } } impl ResponseJson { fn from_response( response: &Response, response_dir: Option<&Path>, secrets: &[&str], ) -> Result { let http_version = match response.version { HttpVersion::Http10 => "HTTP/1.0", HttpVersion::Http11 => "HTTP/1.1", HttpVersion::Http2 => "HTTP/2", HttpVersion::Http3 => "HTTP/3", }; let headers = response .headers .iter() .map(|h| HeaderJson::from_header(h, secrets)) .collect::>(); let cookies = response .cookies() .iter() .map(|c| ResponseCookieJson::from_cookie(c, secrets)) .collect::>(); let certificate = response .certificate .as_ref() .map(CertificateJson::from_certificate); let body = match response_dir { Some(response_dir) => { // FIXME: we save the filename and the parent dir: this feature is used in the // context of the JSON report where the response are stored: // // ``` // response_dir // ├── report.json // └── store // ├── 1fe9d647-5689-4130-b4ea-dc120c2536ba_response.html // ├── 35f49c69-15f9-43df-a672-a1ff5f68c935_response.json // ... // └── ce7f1326-2e2a-46e9-befd-ee0d85084814_response.json // ``` // we want the `body` field to reference the relative path of a response compared // to `report.json`. let file = write_response(response, response_dir)?; let parent = response_dir.components().next_back().unwrap(); let parent: &Path = parent.as_ref(); Some(format!("{}/{}", parent.display(), file.display())) } None => None, }; Ok(ResponseJson { http_version: http_version.to_string(), status: response.status, headers, cookies, certificate, body, }) } } impl TimingsJson { fn from_timings(timings: &Timings) -> Self { TimingsJson { begin_call: timings .begin_call .to_rfc3339_opts(SecondsFormat::Micros, true), end_call: timings .end_call .to_rfc3339_opts(SecondsFormat::Micros, true), name_lookup: timings.name_lookup.as_micros() as u64, connect: timings.connect.as_micros() as u64, app_connect: timings.app_connect.as_micros() as u64, pre_transfer: timings.pre_transfer.as_micros() as u64, start_transfer: timings.start_transfer.as_micros() as u64, total: timings.total.as_micros() as u64, } } } impl HeaderJson { fn from_header(h: &Header, secrets: &[&str]) -> Self { HeaderJson { name: h.name.clone(), value: h.value.redact(secrets), } } } impl RequestCookieJson { fn from_cookie(c: &RequestCookie, secrets: &[&str]) -> Self { RequestCookieJson { name: c.name.clone(), value: c.value.redact(secrets), } } } impl ParamJson { fn from_param(p: &Param, secrets: &[&str]) -> Self { ParamJson { name: p.name.clone(), value: p.value.redact(secrets), } } } impl ResponseCookieJson { fn from_cookie(c: &ResponseCookie, secrets: &[&str]) -> Self { ResponseCookieJson { name: c.name.clone(), value: c.value.redact(secrets), expires: c.expires(), max_age: c.max_age().map(|m| m.to_string()), domain: c.domain(), path: c.path(), secure: if c.has_secure() { Some(true) } else { None }, http_only: if c.has_httponly() { Some(true) } else { None }, same_site: c.samesite(), } } } impl CertificateJson { fn from_certificate(c: &Certificate) -> Self { CertificateJson { subject: c.subject.clone(), issuer: c.issuer.clone(), start_date: c.start_date.to_string(), expire_date: c.expire_date.to_string(), serial_number: c.serial_number.to_string(), } } } impl CaptureJson { fn from_capture(c: &CaptureResult, secrets: &[&str]) -> Self { CaptureJson { name: c.name.clone(), value: c.value.to_json(secrets), } } } impl AssertJson { fn from_assert( a: &AssertResult, content: &str, filename: &Input, entry_src_info: SourceInfo, secrets: &[&str], ) -> Self { let message = a.to_runner_error().map(|err| { err.render( &filename.to_string(), content, Some(entry_src_info), OutputFormat::Plain, ) }); let message = message.map(|m| m.redact(secrets)); AssertJson { success: a.to_runner_error().is_none(), message, line: a.line(), } } } /// Write the HTTP `response` body to directory `dir`. fn write_response(response: &Response, dir: &Path) -> Result { let extension = if response.is_json() { Some("json") } else if response.is_xml() { Some("xml") } else if response.is_html() { Some("html") } else { None }; let id = Uuid::new_v4(); let relative_path = format!("{id}_response"); let relative_path = Path::new(&relative_path); let relative_path = match extension { Some(ext) => relative_path.with_extension(ext), None => relative_path.to_path_buf(), }; let path = dir.join(relative_path.clone()); let mut file = File::create(path)?; file.write_all(&response.body)?; Ok(relative_path) } hurl-7.1.0/src/json/value.rs000064400000000000000000000111211046102023000140210ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::str::FromStr; use base64::engine::general_purpose; use base64::Engine; use crate::runner::{Number, Value}; use crate::util::redacted::Redact; /// Serializes a [`Value`] to JSON, used in captures serialization. /// /// Natural JSON types are used to represent captures: if a [`Value::List`] is captured, /// the serialized data will be a JSON list. /// `secrets` are redacted from string values. impl Value { pub fn to_json(&self, secrets: &[&str]) -> serde_json::Value { match self { Value::Bool(v) => serde_json::Value::Bool(*v), Value::Date(v) => serde_json::Value::String(v.to_string()), Value::Number(v) => v.to_json(), Value::String(s) => serde_json::Value::String(s.redact(secrets)), Value::List(values) => { let values = values.iter().map(|v| v.to_json(secrets)).collect(); serde_json::Value::Array(values) } Value::Object(key_values) => { let mut map = serde_json::Map::new(); for (key, value) in key_values { map.insert(key.to_string(), value.to_json(secrets)); } serde_json::Value::Object(map) } Value::Nodeset(size) => { // For nodeset, we don't have a "native" JSON representation to use as a serialized // format. As a fallback, we serialize with a `type` field: // // ```json // { // "type": "nodeset", // "size": 4, // } // ``` let mut map = serde_json::Map::new(); let size = *size as i64; map.insert( "type".to_string(), serde_json::Value::String("nodeset".to_string()), ); map.insert("size".to_string(), serde_json::Value::from(size)); serde_json::Value::Object(map) } Value::Bytes(v) => { let encoded = general_purpose::STANDARD.encode(v); serde_json::Value::String(encoded) } Value::Null => serde_json::Value::Null, Value::Regex(value) => serde_json::Value::String(value.to_string()), Value::Unit => { // Like nodeset, we don't have a "native" JSON representation for the unit type, // we use a general fallback with `type` field let mut map = serde_json::Map::new(); map.insert( "type".to_string(), serde_json::Value::String("unit".to_string()), ); serde_json::Value::Object(map) } Value::HttpResponse(v) => { let mut map = serde_json::Map::new(); let location = match v.location() { Some(loc) => loc.raw(), None => "None".to_string(), }; map.insert("location".to_string(), serde_json::Value::String(location)); map.insert( "status".to_string(), serde_json::Value::Number(serde_json::Number::from(v.status())), ); serde_json::Value::Object(map) } } } } impl Number { /// Serializes a number to JSON. /// /// Numbers that are representable in JSON use the number JSON type, while big number /// will be serialized as string. pub fn to_json(&self) -> serde_json::Value { match self { Number::Integer(v) => serde_json::Value::Number(serde_json::Number::from(*v)), Number::Float(f) => { serde_json::Value::Number(serde_json::Number::from_f64(*f).unwrap()) } Number::BigInteger(s) => { let number = serde_json::Number::from_str(s).unwrap(); serde_json::Value::Number(number) } } } } hurl-7.1.0/src/jsonpath/ast.rs000064400000000000000000000043501046102023000143570ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ // https://cburgmer.github.io/json-path-comparison/ // https://goessner.net/articles/JsonPath/ // https://jsonpath.com/ #[derive(Clone, Debug, PartialEq, Eq)] pub struct Query { pub selectors: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum Selector { Wildcard, NameChild(String), ArrayIndex(i64), // one unique index - can be negative ArrayIndices(Vec), // two or more indexes (separated by comma) - can be negative ArraySlice(Slice), ArrayWildcard, Filter(Predicate), RecursiveWildcard, RecursiveKey(String), } // For the time-being // use simple slice start:end (without the step) #[derive(Clone, Debug, PartialEq, Eq)] pub struct Slice { pub start: Option, pub end: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Predicate { pub key: Vec, pub func: PredicateFunc, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum PredicateFunc { KeyExist, EqualBool(bool), EqualString(String), NotEqualString(String), Equal(Number), NotEqual(Number), GreaterThan(Number), GreaterThanOrEqual(Number), LessThan(Number), LessThanOrEqual(Number), } // Number // - without rounding // - Equalable #[derive(Clone, Debug, PartialEq, Eq)] pub struct Number { pub int: i64, pub decimal: u64, } impl Number { pub fn to_f64(&self) -> f64 { self.int as f64 + self.decimal as f64 / 1_000_000_000_000_000_000.0 } } #[cfg(test)] mod tests { use super::*; #[test] pub fn test_number() { assert!((Number { int: 1, decimal: 0 }.to_f64() - 1.0).abs() < 0.0000001); } } hurl-7.1.0/src/jsonpath/eval/mod.rs000064400000000000000000000015531046102023000153000ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ pub mod query; mod selector; #[derive(Clone, Debug, PartialEq, Eq)] pub enum JsonpathResult { SingleEntry(serde_json::Value), // returned by a "definite" path Collection(Vec), // returned by a "indefinite" path } hurl-7.1.0/src/jsonpath/eval/query.rs000064400000000000000000000200461046102023000156640ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath::ast::{Query, Selector}; use crate::jsonpath::JsonpathResult; impl Query { /// Eval a JSONPath `Query` for a `serde_json::Value` input. /// It returns an Option<`JsonResultPath`>. pub fn eval(&self, value: &serde_json::Value) -> Option { let mut result = JsonpathResult::SingleEntry(value.clone()); for selector in &self.selectors { match result.clone() { JsonpathResult::SingleEntry(value) => { result = selector.eval(&value)?; } JsonpathResult::Collection(values) => { let mut elements = vec![]; for value in values { if let Some(value) = selector.eval(&value) { match value { JsonpathResult::SingleEntry(new_value) => { elements.push(new_value); } JsonpathResult::Collection(mut new_values) => { elements.append(&mut new_values); } } } } // Retuns nothing (not exists) rather than an empty list with the array index selector // For example with an out of bound index or with an object input if (matches!(selector, Selector::ArrayIndex(_)) && elements.is_empty()) { return None; } result = JsonpathResult::Collection(elements.clone()); } } } Some(result) } } #[cfg(test)] mod tests { use serde_json::json; use crate::jsonpath::ast::{Number, Predicate, PredicateFunc, Query, Selector}; use crate::jsonpath::JsonpathResult; pub fn json_root() -> serde_json::Value { json!({ "store": json_store() }) } pub fn json_store() -> serde_json::Value { json!({ "book": json_books(), "bicycle": [ ] }) } pub fn json_books() -> serde_json::Value { json!([ json_first_book(), json_second_book(), json_third_book(), json_fourth_book() ]) } pub fn json_first_book() -> serde_json::Value { json!({ "category": "reference", "published": false, "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }) } pub fn json_second_book() -> serde_json::Value { json!({ "category": "fiction", "published": false, "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }) } pub fn json_third_book() -> serde_json::Value { json!({ "category": "fiction", "published": true, "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }) } pub fn json_fourth_book() -> serde_json::Value { json!({ "category": "fiction", "published": false, "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 }) } #[test] pub fn test_query() { assert_eq!( Query { selectors: vec![] }.eval(&json_root()).unwrap(), JsonpathResult::SingleEntry(json_root()) ); assert_eq!( Query { selectors: vec![Selector::NameChild("store".to_string())] } .eval(&json_root()) .unwrap(), JsonpathResult::SingleEntry(json_store()) ); let query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::ArrayIndex(0), Selector::NameChild("title".to_string()), ], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::SingleEntry(json!("Sayings of the Century")) ); // $.store.book[?(@.price<10)].title let query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::Filter(Predicate { key: vec!["price".to_string()], func: PredicateFunc::LessThan(Number { int: 10, decimal: 0, }), }), Selector::NameChild("title".to_string()), ], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::Collection(vec![json!("Sayings of the Century"), json!("Moby Dick")]) ); // $.store.book[?(@.published==true)].title let query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::Filter(Predicate { key: vec!["published".to_string()], func: PredicateFunc::EqualBool(true), }), Selector::NameChild("title".to_string()), ], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::Collection(vec![json!("Moby Dick")]) ); // $.store.book[?(@.published==false)].title let query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::Filter(Predicate { key: vec!["published".to_string()], func: PredicateFunc::EqualBool(false), }), Selector::NameChild("title".to_string()), ], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::Collection(vec![ json!("Sayings of the Century"), json!("Sword of Honour"), json!("The Lord of the Rings") ]) ); // $..author let query = Query { selectors: vec![Selector::RecursiveKey("author".to_string())], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::Collection(vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ]) ); // $.store.book[*].author let query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::ArrayWildcard, Selector::NameChild("author".to_string()), ], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::Collection(vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ]) ); } } hurl-7.1.0/src/jsonpath/eval/selector.rs000064400000000000000000000401361046102023000163410ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath::ast::{Predicate, PredicateFunc, Selector, Slice}; use crate::jsonpath::JsonpathResult; /** * Normalize positive/negative index to positive index * Return None if the index is out of bound */ fn normalize_index(elements: &[serde_json::Value], index: i64) -> Option { if index >= elements.len() as i64 { None } else if index >= 0 { Some(index as usize) } else if index < -(elements.len() as i64) { None } else { Some((elements.len() as i64 + index) as usize) } } impl Selector { pub fn eval(&self, root: &serde_json::Value) -> Option { match self { // Selectors returning single JSON node ("finite") Selector::NameChild(field) => root .get(field) .map(|result| JsonpathResult::SingleEntry(result.clone())), Selector::ArrayIndex(index) => { if let serde_json::Value::Array(elements) = root { if let Some(index) = normalize_index(elements, *index) { if let Some(value) = root.get(index) { return Some(JsonpathResult::SingleEntry(value.clone())); } } } None } // Selectors returning a collection ("indefinite") Selector::Wildcard | Selector::ArrayWildcard => { let mut elements = vec![]; if let serde_json::Value::Array(values) = root { for value in values { elements.push(value.clone()); } } else if let serde_json::Value::Object(key_values) = root { for value in key_values.values() { elements.push(value.clone()); } } Some(JsonpathResult::Collection(elements)) } Selector::ArraySlice(Slice { start, end }) => { let mut elements = vec![]; if let serde_json::Value::Array(values) = root { for (i, value) in values.iter().enumerate() { if let Some(n) = start { let n = if *n < 0 { values.len() as i64 + n } else { *n }; if (i as i64) < n { continue; } } if let Some(n) = end { let n = if *n < 0 { values.len() as i64 + n } else { *n }; if (i as i64) >= n { continue; } } elements.push(value.clone()); } } Some(JsonpathResult::Collection(elements)) } Selector::RecursiveKey(key) => { let mut elements = vec![]; match root { serde_json::Value::Object(ref obj) => { if let Some(elem) = obj.get(key.as_str()) { elements.push(elem.clone()); } for value in obj.values() { if let Some(JsonpathResult::Collection(mut values)) = Selector::RecursiveKey(key.clone()).eval(value) { elements.append(&mut values); } } } serde_json::Value::Array(values) => { for value in values { if let Some(JsonpathResult::Collection(mut values)) = Selector::RecursiveKey(key.clone()).eval(value) { elements.append(&mut values); } } } _ => {} } Some(JsonpathResult::Collection(elements)) } Selector::RecursiveWildcard => { let mut elements = vec![]; match root { serde_json::Value::Object(map) => { for elem in map.values() { elements.push(elem.clone()); if let Some(JsonpathResult::Collection(mut values)) = Selector::RecursiveWildcard.eval(elem) { elements.append(&mut values); } } } serde_json::Value::Array(values) => { for elem in values { elements.push(elem.clone()); if let Some(JsonpathResult::Collection(mut values)) = Selector::RecursiveWildcard.eval(elem) { elements.append(&mut values); } } } _ => {} } Some(JsonpathResult::Collection(elements)) } Selector::Filter(predicate) => { let elements = match root { serde_json::Value::Array(elements) => elements .iter() .filter(|&e| predicate.eval(e.clone())) .cloned() .collect(), _ => vec![], }; Some(JsonpathResult::Collection(elements)) } Selector::ArrayIndices(indexes) => { let mut values = vec![]; if let serde_json::Value::Array(elements) = root { for index in indexes { if let Some(index) = normalize_index(elements, *index) { if let Some(value) = root.get(index) { values.push(value.clone()); } } } } Some(JsonpathResult::Collection(values)) } } } } impl Predicate { pub fn eval(&self, elem: serde_json::Value) -> bool { match elem { serde_json::Value::Object(_) => { if let Some(value) = extract_value(elem, self.key.clone()) { match (value, self.func.clone()) { (_, PredicateFunc::KeyExist) => true, (serde_json::Value::Number(v), PredicateFunc::Equal(ref num)) => { (v.as_f64().unwrap() - num.to_f64()).abs() < f64::EPSILON } (serde_json::Value::Number(v), PredicateFunc::GreaterThan(ref num)) => { v.as_f64().unwrap() > num.to_f64() } ( serde_json::Value::Number(v), PredicateFunc::GreaterThanOrEqual(ref num), ) => v.as_f64().unwrap() >= num.to_f64(), (serde_json::Value::Number(v), PredicateFunc::LessThan(ref num)) => { v.as_f64().unwrap() < num.to_f64() } (serde_json::Value::Number(v), PredicateFunc::LessThanOrEqual(ref num)) => { v.as_f64().unwrap() <= num.to_f64() } (serde_json::Value::String(v), PredicateFunc::EqualString(ref s)) => { v == *s } (serde_json::Value::String(v), PredicateFunc::NotEqualString(ref s)) => { v != *s } (serde_json::Value::Number(v), PredicateFunc::NotEqual(ref num)) => { (v.as_f64().unwrap() - num.to_f64()).abs() >= f64::EPSILON } (serde_json::Value::Bool(v), PredicateFunc::EqualBool(ref s)) => v == *s, _ => false, } } else { false } } _ => false, } } } fn extract_value(obj: serde_json::Value, key_path: Vec) -> Option { let mut path = key_path; let mut value = obj; loop { if path.is_empty() { break; } let key = path.remove(0); match value.get(key) { None => return None, Some(v) => value = v.clone(), } } Some(value) } #[cfg(test)] mod tests { use serde_json::json; use super::*; use crate::jsonpath::ast::Number; pub fn json_root() -> serde_json::Value { json!({ "store": json_store() }) } pub fn json_store() -> serde_json::Value { json!({ "book": json_books(), "bicycle": [ ] }) } pub fn json_books() -> serde_json::Value { json!([ json_first_book(), json_second_book(), json_third_book(), json_fourth_book() ]) } pub fn json_first_book() -> serde_json::Value { json!({ "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }) } pub fn json_second_book() -> serde_json::Value { json!({ "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }) } pub fn json_third_book() -> serde_json::Value { json!({ "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }) } pub fn json_fourth_book() -> serde_json::Value { json!({ "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 }) } #[test] pub fn test_selector_name_child() { assert_eq!( Selector::NameChild("author".to_string()) .eval(&json_first_book()) .unwrap(), JsonpathResult::SingleEntry(json!("Nigel Rees")) ); assert!(Selector::NameChild("undefined".to_string()) .eval(&json_first_book()) .is_none(),); } #[test] pub fn test_selector_array_index() { assert_eq!( Selector::ArrayIndex(0).eval(&json_books()).unwrap(), JsonpathResult::SingleEntry(json_first_book()) ); assert_eq!( Selector::ArrayIndex(-1).eval(&json_books()).unwrap(), JsonpathResult::SingleEntry(json_fourth_book()) ); assert_eq!( Selector::ArrayIndices(vec![1, 2]) .eval(&json_books()) .unwrap(), JsonpathResult::Collection(vec![json_second_book(), json_third_book()]) ); } #[test] pub fn test_selector_array_wildcard() { assert_eq!( Selector::ArrayWildcard.eval(&json_books()).unwrap(), JsonpathResult::Collection(vec![ json_first_book(), json_second_book(), json_third_book(), json_fourth_book() ]) ); } #[test] pub fn test_selector_array_slice() { assert_eq!( Selector::ArraySlice(Slice { start: None, end: Some(2), }) .eval(&json_books()) .unwrap(), JsonpathResult::Collection(vec![json_first_book(), json_second_book(),]) ); } #[test] pub fn test_recursive_key() { assert_eq!( Selector::RecursiveKey("author".to_string()) .eval(&json_root()) .unwrap(), JsonpathResult::Collection(vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ]) ); } // tests from https://cburgmer.github.io/json-path-comparison #[test] pub fn test_array_index() { let value = json!(["first", "second", "third", "forth", "fifth"]); assert_eq!( Selector::ArrayIndex(2).eval(&value).unwrap(), JsonpathResult::SingleEntry(json!("third")) ); assert_eq!( Selector::ArrayIndices(vec![2, 3]).eval(&value).unwrap(), JsonpathResult::Collection(vec![json!("third"), json!("forth")]) ); } #[test] pub fn test_predicate() { assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::KeyExist, } .eval(json!({"key": "value"}))); assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualString("value".to_string()), } .eval(json!({"key": "value"}))); assert!(!Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualString("value".to_string()), } .eval(json!({"key": "some"}))); assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::Equal(Number { int: 1, decimal: 0 }), } .eval(json!({"key": 1}))); assert!(!Predicate { key: vec!["key".to_string()], func: PredicateFunc::Equal(Number { int: 1, decimal: 0 }), } .eval(json!({"key": 2}))); assert!(!Predicate { key: vec!["key".to_string()], func: PredicateFunc::Equal(Number { int: 1, decimal: 0 }), } .eval(json!({"key": "1"}))); assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::LessThan(Number { int: 10, decimal: 0, }), } .eval(json!({"key": 1}))); assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualBool(true), } .eval(json!({"key": true}))); assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualBool(false), } .eval(json!({"key": false}))); } #[test] pub fn test_extract_value() { assert_eq!( extract_value(json!({"key": 1}), vec!["key".to_string()]).unwrap(), json!(1) ); assert!(extract_value(json!({"key": 1}), vec!["unknown".to_string()]).is_none()); assert_eq!( extract_value( json!({"key1": {"key2": 1}}), vec!["key1".to_string(), "key2".to_string()] ) .unwrap(), json!(1) ); } #[test] pub fn test_normalize_index() { assert_eq!( normalize_index(&[json!(1), json!(2), json!(3)], 1).unwrap(), 1 ); assert_eq!( normalize_index(&[json!(1), json!(2), json!(3)], -1).unwrap(), 2 ); assert!(normalize_index(&[json!(1), json!(2), json!(3)], 5).is_none()); assert!(normalize_index(&[json!(1), json!(2), json!(3)], -5).is_none()); } } hurl-7.1.0/src/jsonpath/jsonpath.grammar000064400000000000000000000021461046102023000164210ustar 00000000000000query = "$" selector* # # selector # only for array? # ..book[0] first book if book an array selector = name-child-selector | array-index-selector | filter-selector | recursive-key-selector name-child-selector = "[" string-value "]" array-index-selector = "[" integer "]" filter-selector = "[?(" predicate ")]" recursive-key-selector = ".." key-name # # predicate # @.price<10 # predicate = predicate-key predicate-func predicate-key = "@." key-name predicate-func = key-exist-predicate-func | equal-string-predicate-func | notequal-string-predicate-func | equal-number-predicate-func | notequal-number-predicate-func | greater-than-predicate-func | greater-or-equal-than-predicate-func equal-string-predicate-func = "=" string-value equal-number-predicate-func- = "=" number notequal-string-predicate-func = "!=" string-value notequal-number-predicate-func = "!=" number # # Primitives # key-name = string-value = "'" "'" number = hurl-7.1.0/src/jsonpath/mod.rs000064400000000000000000000053671046102023000143600ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! JSONPath specs. //! //! There is no proper specifications for JSONPath. //! The de-facto one is still . //! //! Hurl will try to follow this one as closely as possible //! //! There are a few edge cases for which several implementations differ. //! //! We describe below the behaviour that we expect in Hurl. //! //! Specify a field key in a subscript operator: `$['name']`. //! The key must be enclosed within single quotes only. //! The following expressions will not be valid: `$["name"]` and `$[name]`. //! //! Accessing a key containing a single quote must be escape: `$['\'']`. //! Key with unicode are supported: `$['✈']` //! //! Any character within these quote won't have a specific meaning: //! - `$['*']` selects the element with key '*'. It is different from `$[*]` which selects all elements //! - `$['.']` selects the element with key '.'. //! //! The dot notation is usually more readable the bracket notation //! but it is more limited in terms of allowed characters. //! The following characters are allowed: //! - alphanumeric //! - _ (underscore) //! //! Filters can be applied to element of an array with the `?(@.key PREDICATE)` notation. //! The key can specify one or more levels. //! For example, `.price.US` specify field 'US' in an object for the field price. //! The predicate if not present just checks the key existence. //! //! The Hurl API for evaluating a jsonpath expression does not always return a collection (as defined in the jsonpath spec). //! It returns an optional value, which is either a collection or a single value (scalar). //! Note that other implementations (such as the Java lib ) also distinguish between node value (definite path) and collection (indefinite path). //! //! Note that the only selectors returning a scalar are: //! - array index selector (`$.store.book[2]`) //! - object key selector (`$.store.bicycle.color/$.store.bicycle['color']`) //! //! This will make testing the value a bit easier. //! pub use self::eval::JsonpathResult; pub use self::parser::parse; mod ast; mod eval; mod parser; #[cfg(test)] mod tests; hurl-7.1.0/src/jsonpath/parser/error.rs000064400000000000000000000027601046102023000162200ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::reader::Pos; pub type ParseResult = Result; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ParseError { pub pos: Pos, pub recoverable: bool, pub kind: ParseErrorKind, } impl ParseError { pub fn new(pos: Pos, recoverable: bool, kind: ParseErrorKind) -> Self { ParseError { pos, recoverable, kind, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum ParseErrorKind { Expecting(String), } impl hurl_core::combinator::ParseError for ParseError { fn is_recoverable(&self) -> bool { self.recoverable } fn to_recoverable(self) -> Self { ParseError { recoverable: true, ..self } } fn to_non_recoverable(self) -> Self { ParseError { recoverable: false, ..self } } } hurl-7.1.0/src/jsonpath/parser/mod.rs000064400000000000000000000012701046102023000156410ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ pub use self::parse::parse; mod error; mod parse; mod primitives; hurl-7.1.0/src/jsonpath/parser/parse.rs000064400000000000000000000541071046102023000162030ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::combinator::{choice, zero_or_more}; use hurl_core::reader::Reader; use crate::jsonpath::ast::{Predicate, PredicateFunc, Query, Selector, Slice}; use crate::jsonpath::parser::error::{ParseError, ParseErrorKind, ParseResult}; use crate::jsonpath::parser::primitives::{ boolean, integer, key_name, key_path, literal, number, string_value, try_literal, whitespace, }; pub fn parse(s: &str) -> Result { let mut reader = Reader::new(s); query(&mut reader) } fn query(reader: &mut Reader) -> ParseResult { literal("$", reader)?; let selectors = zero_or_more(selector, reader)?; if !reader.is_eof() { let kind = ParseErrorKind::Expecting("eof".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } Ok(Query { selectors }) } fn selector(reader: &mut Reader) -> ParseResult { choice( &[ selector_filter, selector_wildcard, selector_recursive_wildcard, selector_recursive_key, selector_array_index_or_array_indices, selector_array_wildcard, selector_array_slice, selector_object_key_bracket, selector_object_key, ], reader, ) } fn selector_array_index_or_array_indices(reader: &mut Reader) -> Result { let initial_state = reader.cursor(); try_left_bracket(reader)?; let mut indexes = vec![]; let i = match integer(reader) { Err(e) => { let error = ParseError::new(e.pos, true, e.kind); return Err(error); } Ok(v) => v, }; indexes.push(i); loop { let start = reader.cursor(); if try_literal(",", reader).is_ok() { let i = match integer(reader) { Err(e) => { return Err(ParseError::new(e.pos, true, e.kind)); } Ok(v) => v, }; indexes.push(i); } else { reader.seek(start); break; } } // you will have a ':' for a slice // TODO: combine array index, indices and slice in the same function if let Err(e) = try_literal("]", reader) { reader.seek(initial_state); return Err(ParseError::new(reader.cursor().pos, true, e.kind)); } let selector = if indexes.len() == 1 { let index = *indexes.first().unwrap(); Selector::ArrayIndex(index) } else { Selector::ArrayIndices(indexes) }; Ok(selector) } fn selector_array_wildcard(reader: &mut Reader) -> Result { try_left_bracket(reader)?; try_literal("*", reader)?; literal("]", reader)?; Ok(Selector::ArrayWildcard) } fn selector_array_slice(reader: &mut Reader) -> Result { try_left_bracket(reader)?; let save = reader.cursor(); let start = match integer(reader) { Err(_) => { reader.seek(save); None } Ok(v) => Some(v), }; if try_literal(":", reader).is_err() { let kind = ParseErrorKind::Expecting(":".to_string()); let error = ParseError::new(save.pos, true, kind); return Err(error); }; let save = reader.cursor(); let end = match integer(reader) { Err(_) => { reader.seek(save); None } Ok(v) => Some(v), }; literal("]", reader)?; Ok(Selector::ArraySlice(Slice { start, end })) } fn selector_filter(reader: &mut Reader) -> Result { try_left_bracket(reader)?; try_literal("?(", reader)?; let pred = predicate(reader)?; literal(")]", reader)?; Ok(Selector::Filter(pred)) } fn selector_object_key_bracket(reader: &mut Reader) -> Result { try_left_bracket(reader)?; match string_value(reader) { Err(_) => { let kind = ParseErrorKind::Expecting("value string".to_string()); let error = ParseError::new(reader.cursor().pos, true, kind); Err(error) } Ok(v) => { literal("]", reader)?; Ok(Selector::NameChild(v)) } } } fn selector_object_key(reader: &mut Reader) -> Result { if reader.peek() != Some('.') { let kind = ParseErrorKind::Expecting("[ or .".to_string()); let error = ParseError::new(reader.cursor().pos, true, kind); return Err(error); }; _ = reader.read(); let s = reader.read_while(|c| c.is_alphanumeric() || c == '_' || c == '-'); if s.is_empty() { let kind = ParseErrorKind::Expecting("empty value".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } Ok(Selector::NameChild(s)) } fn selector_wildcard(reader: &mut Reader) -> Result { try_literal(".*", reader)?; Ok(Selector::Wildcard) } fn selector_recursive_wildcard(reader: &mut Reader) -> Result { try_literal("..*", reader)?; Ok(Selector::RecursiveWildcard) } fn selector_recursive_key(reader: &mut Reader) -> Result { try_literal("..", reader)?; let k = key_name(reader)?; Ok(Selector::RecursiveKey(k)) } fn try_left_bracket(reader: &mut Reader) -> Result<(), ParseError> { let start = reader.cursor(); if literal(".[", reader).is_err() { reader.seek(start); try_literal("[", reader)?; } Ok(()) } fn predicate(reader: &mut Reader) -> ParseResult { // predicate always on key? // TODO parsing key-value // ?(@.key=='value') // @<3 => assume number => plan it in your ast => ValueEqualInt should be used for that // KeyValueEqualInt // @.key Exist(Key) // @.key==value Equal(Key,Value) // @.key>=value GreaterThanOrEqual(Key, Value) literal("@.", reader)?; // assume key value for the time being let key = key_path(reader)?; let save = reader.cursor(); let func = match predicate_func(reader) { Ok(f) => f, Err(_) => { reader.seek(save); PredicateFunc::KeyExist } }; Ok(Predicate { key, func }) } fn predicate_func(reader: &mut Reader) -> ParseResult { choice( &[ equal_number_predicate_func, greater_than_predicate_func, greater_than_or_equal_predicate_func, less_than_predicate_func, less_than_or_equal_predicate_func, equal_boolean_predicate_func, equal_string_predicate_func, notequal_string_predicate_func, notequal_number_func, ], reader, ) } fn equal_number_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("==", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::Equal(num)) } fn equal_boolean_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("==", reader)?; whitespace(reader); let boolean = boolean(reader)?; Ok(PredicateFunc::EqualBool(boolean)) } fn greater_than_predicate_func(reader: &mut Reader) -> ParseResult { try_literal(">", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::GreaterThan(num)) } fn greater_than_or_equal_predicate_func(reader: &mut Reader) -> ParseResult { try_literal(">=", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::GreaterThanOrEqual(num)) } fn less_than_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("<", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::LessThan(num)) } fn less_than_or_equal_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("<=", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::LessThanOrEqual(num)) } fn equal_string_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("==", reader)?; whitespace(reader); let s = string_value(reader)?; Ok(PredicateFunc::EqualString(s)) } fn notequal_string_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("!=", reader)?; whitespace(reader); let s = string_value(reader)?; Ok(PredicateFunc::NotEqualString(s)) } fn notequal_number_func(reader: &mut Reader) -> ParseResult { try_literal("!=", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::NotEqual(num)) } #[cfg(test)] mod tests { use hurl_core::reader::{CharPos, Pos}; // tests from https://cburgmer.github.io/json-path-comparison use super::*; use crate::jsonpath::ast::Number; #[test] pub fn test_try_left_bracket() { let mut reader = Reader::new("xxx"); let error = try_left_bracket(&mut reader).err().unwrap(); assert!(error.recoverable); let mut reader = Reader::new("[xxx"); assert!(try_left_bracket(&mut reader).is_ok()); assert_eq!(reader.cursor().index, CharPos(1)); let mut reader = Reader::new(".[xxx"); assert!(try_left_bracket(&mut reader).is_ok()); assert_eq!(reader.cursor().index, CharPos(2)); } #[test] pub fn test_query() { let expected_query = Query { selectors: vec![Selector::ArrayIndex(2)], }; assert_eq!(query(&mut Reader::new("$[2]")).unwrap(), expected_query); let expected_query = Query { selectors: vec![Selector::NameChild("key".to_string())], }; assert_eq!(query(&mut Reader::new("$['key']")).unwrap(), expected_query); assert_eq!(query(&mut Reader::new("$.key")).unwrap(), expected_query); let expected_query = Query { selectors: vec![Selector::NameChild("profile-id".to_string())], }; assert_eq!( query(&mut Reader::new("$['profile-id']")).unwrap(), expected_query ); assert_eq!( query(&mut Reader::new("$.profile-id")).unwrap(), expected_query ); let expected_query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::ArrayIndex(0), Selector::NameChild("title".to_string()), ], }; assert_eq!( query(&mut Reader::new("$.store.book[0].title")).unwrap(), expected_query ); assert_eq!( query(&mut Reader::new("$['store']['book'][0]['title']")).unwrap(), expected_query ); let expected_query = Query { selectors: vec![ Selector::RecursiveKey("book".to_string()), Selector::ArrayIndex(2), ], }; assert_eq!( query(&mut Reader::new("$..book[2]")).unwrap(), expected_query ); } #[test] pub fn test_query_error() { let error = query(&mut Reader::new("?$.store")).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); let error = query(&mut Reader::new("$.store?")).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 8 }); } #[test] pub fn test_selector_filter() { // Filter exist value let mut reader = Reader::new("[?(@.isbn)]"); assert_eq!( selector(&mut reader).unwrap(), Selector::Filter(Predicate { key: vec!["isbn".to_string()], func: PredicateFunc::KeyExist, }) ); assert_eq!(reader.cursor().index, CharPos(11)); // Filter equal on string with single quotes let mut reader = Reader::new("[?(@.key=='value')]"); assert_eq!( selector(&mut reader).unwrap(), Selector::Filter(Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualString("value".to_string()), }) ); assert_eq!(reader.cursor().index, CharPos(19)); let mut reader = Reader::new(".[?(@.key=='value')]"); assert_eq!( selector(&mut reader).unwrap(), Selector::Filter(Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualString("value".to_string()), }) ); assert_eq!(reader.cursor().index, CharPos(20)); let mut reader = Reader::new("[?(@.price<10)]"); assert_eq!( selector(&mut reader).unwrap(), Selector::Filter(Predicate { key: vec!["price".to_string()], func: PredicateFunc::LessThan(Number { int: 10, decimal: 0 }), }) ); assert_eq!(reader.cursor().index, CharPos(15)); } #[test] pub fn test_selector_recursive() { let mut reader = Reader::new("..book"); assert_eq!( selector(&mut reader).unwrap(), Selector::RecursiveKey("book".to_string()) ); assert_eq!(reader.cursor().index, CharPos(6)); } #[test] pub fn test_selector_array_index() { let mut reader = Reader::new("[2]"); assert_eq!(selector(&mut reader).unwrap(), Selector::ArrayIndex(2)); assert_eq!(reader.cursor().index, CharPos(3)); let mut reader = Reader::new("[0,1]"); assert_eq!( selector(&mut reader).unwrap(), Selector::ArrayIndices(vec![0, 1]) ); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new("[-1]"); assert_eq!(selector(&mut reader).unwrap(), Selector::ArrayIndex(-1)); assert_eq!(reader.cursor().index, CharPos(4)); // you don't need to keep the exact string // this is not part of the AST let mut reader = Reader::new(".[2]"); assert_eq!(selector(&mut reader).unwrap(), Selector::ArrayIndex(2)); assert_eq!(reader.cursor().index, CharPos(4)); } #[test] pub fn test_selector_wildcard() { let mut reader = Reader::new("[*]"); assert_eq!(selector(&mut reader).unwrap(), Selector::ArrayWildcard); assert_eq!(reader.cursor().index, CharPos(3)); // you don't need to keep the exact string // this is not part of the AST let mut reader = Reader::new(".[*]"); assert_eq!(selector(&mut reader).unwrap(), Selector::ArrayWildcard); assert_eq!(reader.cursor().index, CharPos(4)); } #[test] pub fn test_selector_array_slice() { let mut reader = Reader::new("[1:]"); assert_eq!( selector(&mut reader).unwrap(), Selector::ArraySlice(Slice { start: Some(1), end: None }) ); assert_eq!(reader.cursor().index, CharPos(4)); let mut reader = Reader::new("[-1:]"); assert_eq!( selector(&mut reader).unwrap(), Selector::ArraySlice(Slice { start: Some(-1), end: None }) ); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new("[:2]"); assert_eq!( selector(&mut reader).unwrap(), Selector::ArraySlice(Slice { start: None, end: Some(2) }) ); assert_eq!(reader.cursor().index, CharPos(4)); } #[test] pub fn test_key_bracket_selector() { let mut reader = Reader::new("['key']"); assert_eq!( selector(&mut reader).unwrap(), Selector::NameChild("key".to_string()) ); assert_eq!(reader.cursor().index, CharPos(7)); let mut reader = Reader::new(".['key']"); assert_eq!( selector(&mut reader).unwrap(), Selector::NameChild("key".to_string()) ); assert_eq!(reader.cursor().index, CharPos(8)); let mut reader = Reader::new("['key1']"); assert_eq!( selector(&mut reader).unwrap(), Selector::NameChild("key1".to_string()) ); assert_eq!(reader.cursor().index, CharPos(8)); } #[test] pub fn test_selector_key_dot_notation() { let mut reader = Reader::new(".key"); assert_eq!( selector(&mut reader).unwrap(), Selector::NameChild("key".to_string()) ); assert_eq!(reader.cursor().index, CharPos(4)); let mut reader = Reader::new(".key1"); assert_eq!( selector(&mut reader).unwrap(), Selector::NameChild("key1".to_string()) ); assert_eq!(reader.cursor().index, CharPos(5)); } #[test] pub fn test_predicate() { // Key exists assert_eq!( predicate(&mut Reader::new("@.isbn")).unwrap(), Predicate { key: vec!["isbn".to_string()], func: PredicateFunc::KeyExist, } ); // Filter equal on string with single quotes assert_eq!( predicate(&mut Reader::new("@.key=='value'")).unwrap(), Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualString("value".to_string()), } ); // Filter equal on int assert_eq!( predicate(&mut Reader::new("@.key==1")).unwrap(), Predicate { key: vec!["key".to_string()], func: PredicateFunc::Equal(Number { int: 1, decimal: 0 }), } ); // Filter equal on int for key in object assert_eq!( predicate(&mut Reader::new("@.obj.key==1")).unwrap(), Predicate { key: vec!["obj".to_string(), "key".to_string()], func: PredicateFunc::Equal(Number { int: 1, decimal: 0 }), } ); // Filter less than int assert_eq!( predicate(&mut Reader::new("@.price<10")).unwrap(), Predicate { key: vec!["price".to_string()], func: PredicateFunc::LessThan(Number { int: 10, decimal: 0 }), } ); } #[test] pub fn test_predicate_func() { let mut reader = Reader::new("==true"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::EqualBool(true) ); assert_eq!(reader.cursor().index, CharPos(6)); let mut reader = Reader::new("==false"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::EqualBool(false) ); assert_eq!(reader.cursor().index, CharPos(7)); let mut reader = Reader::new("==2"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::Equal(Number { int: 2, decimal: 0 }) ); assert_eq!(reader.cursor().index, CharPos(3)); let mut reader = Reader::new("==2.1"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::Equal(Number { int: 2, decimal: 100_000_000_000_000_000 }) ); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new("== 2.1 "); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::Equal(Number { int: 2, decimal: 100_000_000_000_000_000 }) ); assert_eq!(reader.cursor().index, CharPos(7)); let mut reader = Reader::new("=='hello'"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::EqualString("hello".to_string()) ); assert_eq!(reader.cursor().index, CharPos(9)); let mut reader = Reader::new("!='hello'"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::NotEqualString("hello".to_string()) ); let mut reader = Reader::new("!=2"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::NotEqual(Number { int: 2, decimal: 0 }) ); let mut reader = Reader::new("!=2.5"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::NotEqual(Number { int: 2, decimal: 500_000_000_000_000_000 }) ); let mut reader = Reader::new(">5"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::GreaterThan(Number { int: 5, decimal: 0 }) ); assert_eq!(reader.cursor().index, CharPos(2)); let mut reader = Reader::new(">=5"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::GreaterThanOrEqual(Number { int: 5, decimal: 0 }) ); assert_eq!(reader.cursor().index, CharPos(3)); let mut reader = Reader::new("<5"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::LessThan(Number { int: 5, decimal: 0 }) ); assert_eq!(reader.cursor().index, CharPos(2)); let mut reader = Reader::new("<=5"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::LessThanOrEqual(Number { int: 5, decimal: 0 }) ); assert_eq!(reader.cursor().index, CharPos(3)); } } hurl-7.1.0/src/jsonpath/parser/primitives.rs000064400000000000000000000362331046102023000172640ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::reader::Reader; use crate::jsonpath::ast::Number; use crate::jsonpath::parser::error::{ParseError, ParseErrorKind, ParseResult}; pub fn natural(reader: &mut Reader) -> ParseResult { let start = reader.cursor(); if reader.is_eof() { let kind = ParseErrorKind::Expecting("natural".to_string()); let error = ParseError::new(start.pos, true, kind); return Err(error); } let first_digit = reader.read().unwrap(); if !first_digit.is_ascii_digit() { let kind = ParseErrorKind::Expecting("natural".to_string()); let error = ParseError::new(start.pos, true, kind); return Err(error); } let save = reader.cursor(); let s = reader.read_while(|c| c.is_ascii_digit()); // if the first digit is zero, you should not have any more digits if first_digit == '0' && !s.is_empty() { let kind = ParseErrorKind::Expecting("natural".to_string()); let error = ParseError::new(save.pos, false, kind); return Err(error); } Ok(format!("{first_digit}{s}").parse().unwrap()) } pub fn integer(reader: &mut Reader) -> ParseResult { let sign = if reader.peek() == Some('-') { _ = reader.read(); -1 } else { 1 }; let nat = natural(reader)?; Ok(sign * (nat as i64)) } pub fn number(reader: &mut Reader) -> ParseResult { let int = integer(reader)?; let decimal = if reader.peek() == Some('.') { _ = reader.read(); if reader.is_eof() { let kind = ParseErrorKind::Expecting("natural".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } let s = reader.read_while(|c| c.is_ascii_digit()); if s.is_empty() { let kind = ParseErrorKind::Expecting("natural".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } format!("{s:0<18}").parse().unwrap() } else { 0 }; whitespace(reader); Ok(Number { int, decimal }) } pub fn boolean(reader: &mut Reader) -> ParseResult { let token = reader.read_while(|c| c.is_alphabetic()); // Match the token against the strings "true" and "false" let result = match token.as_str() { "true" => Ok(true), "false" => Ok(false), _ => { let kind = ParseErrorKind::Expecting("bool".to_string()); let error = ParseError::new(reader.cursor().pos, true, kind); Err(error) } }; whitespace(reader); result } pub fn string_value(reader: &mut Reader) -> Result { try_literal("'", reader)?; let mut s = String::new(); loop { match reader.read() { None => { let kind = ParseErrorKind::Expecting("'".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } Some('\'') => break, Some('\\') => { // only single quote can be escaped match reader.read() { Some('\'') => { s.push('\''); } _ => { let kind = ParseErrorKind::Expecting("'".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } } } Some(c) => { s.push(c); } } } whitespace(reader); Ok(s) } pub fn key_name(reader: &mut Reader) -> Result { // test python or javascript // subset that can used for dot notation // The key must not be empty and must not start with a digit let first_char = match reader.read() { Some(c) => { if c.is_alphabetic() || c == '_' { c } else { let kind = ParseErrorKind::Expecting("key".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } } None => { let kind = ParseErrorKind::Expecting("key".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } }; let s = reader.read_while(|c| c.is_alphanumeric() || c == '_'); whitespace(reader); Ok(format!("{first_char}{s}")) } // key1.key2.key3 pub fn key_path(reader: &mut Reader) -> Result, ParseError> { let root = key_name(reader)?; let mut path = vec![root]; while let Some('.') = reader.peek() { reader.read(); let key = key_name(reader)?; path.push(key); } Ok(path) } pub fn literal(s: &str, reader: &mut Reader) -> ParseResult<()> { // does not return a value // non recoverable reader // => use combinator recover to make it recoverable let start = reader.cursor(); if reader.is_eof() { let kind = ParseErrorKind::Expecting(s.to_string()); let error = ParseError::new(start.pos, false, kind); return Err(error); } for c in s.chars() { match reader.read() { None => { let kind = ParseErrorKind::Expecting(s.to_string()); let error = ParseError::new(start.pos, false, kind); return Err(error); } Some(x) => { if x != c { let kind = ParseErrorKind::Expecting(s.to_string()); let error = ParseError::new(start.pos, false, kind); return Err(error); } else { continue; } } } } whitespace(reader); Ok(()) } pub fn try_literal(s: &str, p: &mut Reader) -> ParseResult<()> { match literal(s, p) { Ok(_) => Ok(()), Err(ParseError { pos, kind, .. }) => Err(ParseError { pos, recoverable: true, kind, }), } } pub fn whitespace(reader: &mut Reader) { while reader.peek() == Some(' ') { reader.read(); } } #[cfg(test)] mod tests { use hurl_core::reader::{CharPos, Pos}; use super::*; #[test] fn test_natural() { let mut reader = Reader::new("0"); assert_eq!(natural(&mut reader).unwrap(), 0); assert_eq!(reader.cursor().index, CharPos(1)); let mut reader = Reader::new("0."); assert_eq!(natural(&mut reader).unwrap(), 0); assert_eq!(reader.cursor().index, CharPos(1)); let mut reader = Reader::new("10x"); assert_eq!(natural(&mut reader).unwrap(), 10); assert_eq!(reader.cursor().index, CharPos(2)); } #[test] fn test_natural_error() { let mut reader = Reader::new(""); let error = natural(&mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert!(error.recoverable); let mut reader = Reader::new("01"); let error = natural(&mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 2 }); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert!(!error.recoverable); let mut reader = Reader::new("x"); let error = natural(&mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert!(error.recoverable); } #[test] pub fn test_integer() { let mut reader = Reader::new("1"); assert_eq!(integer(&mut reader).unwrap(), 1); let mut reader = Reader::new("1.1"); assert_eq!(integer(&mut reader).unwrap(), 1); let mut reader = Reader::new("-1.1"); assert_eq!(integer(&mut reader).unwrap(), -1); let mut reader = Reader::new("x"); let error = integer(&mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert!(error.recoverable); } #[test] fn test_number() { let mut reader = Reader::new("1"); assert_eq!(number(&mut reader).unwrap(), Number { int: 1, decimal: 0 }); assert_eq!(reader.cursor().index, CharPos(1)); let mut reader = Reader::new("1.0"); assert_eq!(number(&mut reader).unwrap(), Number { int: 1, decimal: 0 }); assert_eq!(reader.cursor().index, CharPos(3)); let mut reader = Reader::new("-1.0"); assert_eq!( number(&mut reader).unwrap(), Number { int: -1, decimal: 0 } ); assert_eq!(reader.cursor().index, CharPos(4)); let mut reader = Reader::new("1.1"); assert_eq!( number(&mut reader).unwrap(), Number { int: 1, decimal: 100_000_000_000_000_000 } ); assert_eq!(reader.cursor().index, CharPos(3)); let mut reader = Reader::new("1.100"); assert_eq!( number(&mut reader).unwrap(), Number { int: 1, decimal: 100_000_000_000_000_000 } ); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new("1.01"); assert_eq!( number(&mut reader).unwrap(), Number { int: 1, decimal: 10_000_000_000_000_000 } ); assert_eq!(reader.cursor().index, CharPos(4)); let mut reader = Reader::new("1.010"); assert_eq!( number(&mut reader).unwrap(), Number { int: 1, decimal: 10_000_000_000_000_000 } ); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new("-0.333333333333333333"); assert_eq!( number(&mut reader).unwrap(), Number { int: 0, decimal: 333_333_333_333_333_333 } ); assert_eq!(reader.cursor().index, CharPos(21)); } #[test] fn test_number_error() { let mut reader = Reader::new(""); let error = number(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert!(error.recoverable); let mut reader = Reader::new("-"); let error = number(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 2 }); assert!(error.recoverable); let mut reader = Reader::new("1."); let error = number(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 3 }); assert!(!error.recoverable); let mut reader = Reader::new("1.x"); let error = number(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 3 }); assert!(!error.recoverable); } #[test] fn test_string_value() { let mut reader = Reader::new("'hello'"); assert_eq!(string_value(&mut reader).unwrap(), "hello".to_string()); let mut reader = Reader::new("'\\''"); assert_eq!(string_value(&mut reader).unwrap(), "'".to_string()); let mut reader = Reader::new("1"); let error = string_value(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("'".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert!(error.recoverable); let mut reader = Reader::new("'hi"); let error = string_value(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("'".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 4 }); assert!(!error.recoverable); } #[test] fn test_key_name() { let mut reader = Reader::new("id'"); assert_eq!(key_name(&mut reader).unwrap(), "id".to_string()); let mut reader = Reader::new("id123"); assert_eq!(key_name(&mut reader).unwrap(), "id123".to_string()); let mut reader = Reader::new("."); let error = key_name(&mut reader).err().unwrap(); assert!(!error.recoverable); assert_eq!(error.kind, ParseErrorKind::Expecting("key".to_string())); let mut reader = Reader::new("1id"); let error = key_name(&mut reader).err().unwrap(); assert!(!error.recoverable); assert_eq!(error.kind, ParseErrorKind::Expecting("key".to_string())); } #[test] fn test_key_path() { let mut reader = Reader::new("id"); assert_eq!(key_path(&mut reader).unwrap(), vec!["id".to_string()]); let mut reader = Reader::new("key1.key2"); assert_eq!( key_path(&mut reader).unwrap(), vec!["key1".to_string(), "key2".to_string()] ); } #[test] fn test_literal() { let mut reader = Reader::new("hello"); assert_eq!(literal("hello", &mut reader), Ok(())); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new("hello "); assert_eq!(literal("hello", &mut reader), Ok(())); assert_eq!(reader.cursor().index, CharPos(6)); let mut reader = Reader::new(""); let error = literal("hello", &mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("hello".to_string())); assert_eq!(reader.cursor().index, CharPos(0)); let mut reader = Reader::new("hi"); let error = literal("hello", &mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("hello".to_string())); assert_eq!(reader.cursor().index, CharPos(2)); let mut reader = Reader::new("he"); let error = literal("hello", &mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("hello".to_string())); assert_eq!(reader.cursor().index, CharPos(2)); } } hurl-7.1.0/src/jsonpath/tests/mod.rs000064400000000000000000000320461046102023000155140ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! Integration tests for jsonpath module. //! These tests are not located at the root of the project, like Rust integration tests //! are usually located since we do not want to expose the jsonpath module to our public API. use std::fs::read_to_string; use serde_json::json; use crate::jsonpath; use crate::jsonpath::JsonpathResult; fn bookstore_value() -> serde_json::Value { let s = read_to_string("tests/bookstore.json").expect("could not read string from file"); serde_json::from_str(s.as_str()).expect("could not parse json file") } fn store_value() -> serde_json::Value { serde_json::from_str( r#" { "book": [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ], "bicycle": { "color": "red", "price": 19.95 } } "#, ) .unwrap() } fn book_value() -> serde_json::Value { serde_json::from_str( r#" [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ] "#, ) .unwrap() } fn bicycle_value() -> serde_json::Value { serde_json::from_str( r#" { "color": "red", "price": 19.95 } "#, ) .unwrap() } fn book0_value() -> serde_json::Value { json!( { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }) } fn book1_value() -> serde_json::Value { json!( { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }) } fn book2_value() -> serde_json::Value { json!( { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }) } fn book3_value() -> serde_json::Value { json!({ "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 }) } #[test] fn test_bookstore_path() { // examples from https://goessner.net/articles/JsonPath/ // the authors of all books in the store let expr = jsonpath::parse("$.store.book[*].author").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ]) ); // all authors let expr = jsonpath::parse("$..author").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ]) ); // all things in store, which are some books and a red bicycle. let expr = jsonpath::parse("$.store.*").unwrap(); // Attention, there is no ordering on object keys with serde_json // But you expect that order stays the same // that's why bicycle and boot are inverted assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![bicycle_value(), book_value()]) ); // the price of everything in the store. let expr = jsonpath::parse("$.store..price").unwrap(); // Attention, there is no ordering on object keys with serde_json // But you expect that order stays the same assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![ json!(19.95), json!(8.95), json!(12.99), json!(8.99), json!(22.99), ]) ); // the third book let expr = jsonpath::parse("$..book[2]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book2_value()]) ); // the last book in order // The following expression is not supported // (@.length-1) // use python-like indexing instead let expr = jsonpath::parse("$..book[-1:]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book3_value()]) ); // the first two books let expr = jsonpath::parse("$..book[0,1]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book0_value(), book1_value()]) ); let expr = jsonpath::parse("$..book[:2]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book0_value(), book1_value()]) ); // filter all books with isbn number let expr = jsonpath::parse("$..book[?(@.isbn)]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book2_value(), book3_value()]) ); // filter all books cheaper than 10 let expr = jsonpath::parse("$..book[?(@.price<10)]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book0_value(), book2_value()]) ); // get all books whose title is not "hamlet". let expr = jsonpath::parse("$..book[?(@.title!='Moby Dick')]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book0_value(), book1_value(), book3_value()]) ); // get all books whose price is not 8.95 (first book) let expr = jsonpath::parse("$..book[?(@.price!=8.95)]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book1_value(), book2_value(), book3_value()]) ); // All members of JSON structure let expr = jsonpath::parse("$..*").unwrap(); // Order is reproducible // but does not keep same order of json input! assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![ store_value(), bicycle_value(), json!("red"), json!(19.95), book_value(), book0_value(), json!("Nigel Rees"), json!("reference"), json!(8.95), json!("Sayings of the Century"), book1_value(), json!("Evelyn Waugh"), json!("fiction"), json!(12.99), json!("Sword of Honour"), book2_value(), json!("Herman Melville"), json!("fiction"), json!("0-553-21311-3"), json!(8.99), json!("Moby Dick"), book3_value(), json!("J. R. R. Tolkien"), json!("fiction"), json!("0-395-19395-8"), json!(22.99), json!("The Lord of the Rings"), ]) ); } #[test] fn test_bookstore_additional() { // Find books more expensive than 100 let expr = jsonpath::parse("$.store.book[?(@.price>100)]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![]) ); // find all authors for reference book let expr = jsonpath::parse("$..book[?(@.category=='reference')].author").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![json!("Nigel Rees")]) ); } #[test] fn test_array() { let array = json!([0, 1, 2, 3]); let expr = jsonpath::parse("$[2]").unwrap(); assert_eq!( expr.eval(&array).unwrap(), JsonpathResult::SingleEntry(json!(2)) ); let expr = jsonpath::parse("$[0].name").unwrap(); let array = json!([{"name": "Bob"},{"name": "Bill"}]); assert_eq!( expr.eval(&array).unwrap(), JsonpathResult::SingleEntry(json!("Bob")) ); } #[test] fn test_key_access() { let obj = json!({ "_": "underscore", "-": "hyphen", "*": "asterisk", "'": "single_quote", "\"": "double_quote", "✈": "plane" }); // Bracket notation let expr = jsonpath::parse("$['-']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("hyphen")) ); let expr = jsonpath::parse("$['_']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("underscore")) ); let expr = jsonpath::parse("$['*']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("asterisk")) ); let expr = jsonpath::parse("$['\\'']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("single_quote")) ); let expr = jsonpath::parse("$['\"']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("double_quote")) ); let expr = jsonpath::parse("$['✈']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("plane")) ); // Dot notation let expr = jsonpath::parse("$._").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("underscore")) ); // Asterisk // return all elements // There is no ordering in JSON keys // You must compare with their string values sorted let values = vec![ "asterisk", "double_quote", "hyphen", "plane", "single_quote", "underscore", ]; let expr = jsonpath::parse("$.*").unwrap(); let results = expr.eval(&obj).unwrap(); if let JsonpathResult::Collection(results) = results { let mut results = results .iter() .map(|e| e.as_str().unwrap()) .collect::>(); results.sort_unstable(); assert_eq!(results, values); } let expr = jsonpath::parse("$[*]").unwrap(); let results = expr.eval(&obj).unwrap(); if let JsonpathResult::Collection(results) = results { let mut results = results .iter() .map(|e| e.as_str().unwrap()) .collect::>(); results.sort_unstable(); assert_eq!(results, values); } } fn fruit_prices_value() -> serde_json::Value { serde_json::from_str( r#" { "fruit": [ { "name": "apple", "price": { "US": 100, "UN": 110 } }, { "name": "grape", "price": { "US": 200, "UN": 150 } } ] } "#, ) .unwrap() } #[test] fn test_filter_nested_object() { let expr = jsonpath::parse("$.fruit[?(@.price.US==200)].name").unwrap(); assert_eq!( expr.eval(&fruit_prices_value()).unwrap(), JsonpathResult::Collection(vec![json!("grape")]) ); let expr = jsonpath::parse("$.fruit[?(@.pricex.US==200)].name").unwrap(); assert_eq!( expr.eval(&fruit_prices_value()).unwrap(), JsonpathResult::Collection(vec![]) ); } #[test] fn test_parsing_error() { // not supported yet assert!(jsonpath::parse("$..book[(@.length-1)]").is_err()); } #[test] fn test_filter_collection_with_nonexisting_field() { let expr = jsonpath::parse("$.book[*].isbn").unwrap(); assert_eq!( expr.eval(&store_value()).unwrap(), JsonpathResult::Collection(vec![json!("0-553-21311-3"), json!("0-395-19395-8"),]) ); } hurl-7.1.0/src/jsonpath2/ast/comparison.rs000064400000000000000000000031441046102023000166130ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath2::ast::literal::Literal; use crate::jsonpath2::ast::singular_query::SingularQuery; #[derive(Clone, Debug, PartialEq, Eq)] #[allow(dead_code)] pub struct ComparisonExpr { left: Comparable, right: Comparable, operator: ComparisonOp, } impl ComparisonExpr { pub fn new(left: Comparable, right: Comparable, operator: ComparisonOp) -> ComparisonExpr { ComparisonExpr { left, right, operator, } } pub fn left(&self) -> &Comparable { &self.left } pub fn right(&self) -> &Comparable { &self.right } pub fn operator(&self) -> &ComparisonOp { &self.operator } } #[derive(Clone, Debug, PartialEq, Eq)] #[allow(dead_code)] pub enum Comparable { Literal(Literal), SingularQuery(SingularQuery), } #[allow(dead_code)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum ComparisonOp { Equal, NotEqual, LessOrEqual, Less, GreaterOrEqual, Greater, } hurl-7.1.0/src/jsonpath2/ast/expr.rs000064400000000000000000000060011046102023000154120ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use super::comparison::ComparisonExpr; use super::query::Query; /// Logical expression /// evaluates to a boolean value #[derive(Clone, Debug, PartialEq, Eq)] pub enum LogicalExpr { /// Comparison expression (e.g., @.price > 10) #[allow(dead_code)] Comparison(ComparisonExpr), /// Test expression (e.g., @.name) #[allow(dead_code)] Test(TestExpr), /// Logical AND expression (e.g., expr1 && expr2 && expr3) #[allow(dead_code)] And(AndExpr), /// Logical OR expression (e.g., expr1 || expr2 || expr3) #[allow(dead_code)] Or(OrExpr), /// Logical NOT expression (e.g., !expr) #[allow(dead_code)] Not(NotExpr), } #[derive(Clone, Debug, PartialEq, Eq)] pub struct TestExpr { not: bool, kind: TestExprKind, } impl TestExpr { #[allow(dead_code)] pub fn new(not: bool, kind: TestExprKind) -> Self { Self { not, kind } } #[allow(dead_code)] pub fn not(&self) -> bool { self.not } #[allow(dead_code)] pub fn kind(&self) -> &TestExprKind { &self.kind } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum TestExprKind { #[allow(dead_code)] FilterQuery(Query), #[allow(dead_code)] FunctionExpr(FunctionExpr), } #[derive(Clone, Debug, PartialEq, Eq)] pub struct FunctionExpr; /// Logical AND expression that can handle multiple operands #[derive(Clone, Debug, PartialEq, Eq)] pub struct AndExpr { operands: Vec, } impl AndExpr { #[allow(dead_code)] pub fn new(operands: Vec) -> Self { Self { operands } } #[allow(dead_code)] pub fn operands(&self) -> &Vec { &self.operands } } /// Logical OR expression that can handle multiple operands #[derive(Clone, Debug, PartialEq, Eq)] pub struct OrExpr { operands: Vec, } impl OrExpr { #[allow(dead_code)] pub fn new(operands: Vec) -> Self { Self { operands } } #[allow(dead_code)] pub fn operands(&self) -> &Vec { &self.operands } } /// Logical AND expression #[derive(Clone, Debug, PartialEq, Eq)] pub struct NotExpr { expr: Box, } impl NotExpr { #[allow(dead_code)] pub fn new(expr: LogicalExpr) -> Self { Self { expr: Box::new(expr), } } #[allow(dead_code)] pub fn expr(&self) -> &LogicalExpr { &self.expr } } hurl-7.1.0/src/jsonpath2/ast/literal.rs000064400000000000000000000014531046102023000160760ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ #[derive(Clone, Debug, PartialEq)] #[allow(dead_code)] pub enum Literal { Bool(bool), Integer(i32), Null, Number(f64), String(String), } impl Eq for Literal {} hurl-7.1.0/src/jsonpath2/ast/mod.rs000064400000000000000000000020231046102023000152130ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ pub(crate) mod comparison; pub(crate) mod expr; pub(crate) mod literal; pub(crate) mod query; pub(crate) mod segment; pub(crate) mod selector; pub(crate) mod singular_query; /// JSONPath Query /// https://www.rfc-editor.org/rfc/rfc9535.html#name-overview-of-jsonpath-expres /// This is the standard JSONPath query used outside the module #[allow(dead_code)] pub(crate) type JsonPathQuery = query::AbsoluteQuery; hurl-7.1.0/src/jsonpath2/ast/query.rs000064400000000000000000000040571046102023000156120ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::fmt::Display; use super::segment::Segment; /// Generic Query /// This query is only used inside the jsonpath module /// can be either absolute with the root identifier $ /// or relative with the current node identifier @ #[allow(dead_code)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum Query { AbsoluteQuery(AbsoluteQuery), RelativeQuery(RelativeQuery), } /// Absolute Query /// This is the standard JsonPath Query starting with the root identifier $ #[derive(Clone, Debug, PartialEq, Eq)] pub struct AbsoluteQuery { segments: Vec, } impl AbsoluteQuery { pub fn new(segments: Vec) -> AbsoluteQuery { AbsoluteQuery { segments } } pub fn segments(&self) -> &[Segment] { &self.segments } } impl Display for AbsoluteQuery { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let segments = self .segments .iter() .map(|s| s.to_string()) .collect::>() .join(""); write!(f, "${segments}") } } #[derive(Clone, Debug, PartialEq, Eq)] /// RelativeQuery /// This query is used inside a filter selector pub struct RelativeQuery { segments: Vec, } impl RelativeQuery { #[allow(dead_code)] pub fn new(segments: Vec) -> RelativeQuery { RelativeQuery { segments } } pub fn segments(&self) -> &[Segment] { &self.segments } } hurl-7.1.0/src/jsonpath2/ast/segment.rs000064400000000000000000000041151046102023000161020ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::fmt::{Display, Formatter}; use super::selector::Selector; /// JSONPath segment /// https://www.rfc-editor.org/rfc/rfc9535.html#name-segments-2 #[allow(dead_code)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum Segment { Child(ChildSegment), Descendant(DescendantSegment), } impl Display for Segment { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Segment::Child(child_segment) => write!(f, "{}", child_segment), Segment::Descendant(descendant_segment) => write!(f, "{}", descendant_segment), } } } /// Child segment #[derive(Clone, Debug, PartialEq, Eq)] pub struct ChildSegment { selectors: Vec, } impl ChildSegment { pub fn new(selectors: Vec) -> ChildSegment { ChildSegment { selectors } } pub fn selectors(&self) -> &[Selector] { &self.selectors } } impl Display for ChildSegment { fn fmt(&self, _f: &mut Formatter<'_>) -> std::fmt::Result { todo!() } } /// Descendant segment #[derive(Clone, Debug, PartialEq, Eq)] pub struct DescendantSegment { selectors: Vec, } impl DescendantSegment { pub fn new(selectors: Vec) -> DescendantSegment { DescendantSegment { selectors } } pub fn selectors(&self) -> &[Selector] { &self.selectors } } impl Display for DescendantSegment { fn fmt(&self, _f: &mut Formatter<'_>) -> std::fmt::Result { todo!() } } hurl-7.1.0/src/jsonpath2/ast/selector.rs000064400000000000000000000050451046102023000162630ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use super::expr::LogicalExpr; /// Selector /// https://www.rfc-editor.org/rfc/rfc9535.html#name-selectors-2 #[allow(dead_code)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum Selector { Name(NameSelector), Wildcard(WildcardSelector), Index(IndexSelector), ArraySlice(ArraySliceSelector), Filter(FilterSelector), } /// Name selector /// selects at most one object member value #[derive(Clone, Debug, PartialEq, Eq)] pub struct NameSelector { value: String, } impl NameSelector { pub fn new(value: String) -> NameSelector { NameSelector { value } } pub fn value(&self) -> &str { &self.value } } /// Wildcard selector #[derive(Clone, Debug, PartialEq, Eq)] pub struct WildcardSelector; /// Index selector /// matches at most one array element value. #[derive(Clone, Debug, PartialEq, Eq)] pub struct IndexSelector { value: i32, } impl IndexSelector { pub fn new(value: i32) -> IndexSelector { IndexSelector { value } } pub fn value(&self) -> &i32 { &self.value } } /// Array slice selector /// :: #[derive(Clone, Debug, PartialEq, Eq)] pub struct ArraySliceSelector { start: Option, end: Option, step: i32, } impl ArraySliceSelector { pub fn new(start: Option, end: Option, step: i32) -> ArraySliceSelector { ArraySliceSelector { start, end, step } } pub fn start(&self) -> Option { self.start } pub fn end(&self) -> Option { self.end } pub fn step(&self) -> i32 { self.step } } /// Filter selector /// used to iterate over the elements or members of structured values, #[derive(Clone, Debug, PartialEq, Eq)] pub struct FilterSelector { expr: LogicalExpr, } impl FilterSelector { pub fn new(expr: LogicalExpr) -> FilterSelector { FilterSelector { expr } } pub fn expr(&self) -> &LogicalExpr { &self.expr } } hurl-7.1.0/src/jsonpath2/ast/singular_query.rs000064400000000000000000000033031046102023000175070ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath2::ast::selector::{IndexSelector, NameSelector}; #[allow(dead_code)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum SingularQuery { Absolute(AbsoluteSingularQuery), Relative(RelativeSingularQuery), } #[derive(Clone, Debug, PartialEq, Eq)] pub struct AbsoluteSingularQuery { segments: Vec, } impl AbsoluteSingularQuery { pub fn new(segments: Vec) -> AbsoluteSingularQuery { AbsoluteSingularQuery { segments } } pub fn segments(&self) -> &[SingularQuerySegment] { &self.segments } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RelativeSingularQuery { segments: Vec, } impl RelativeSingularQuery { pub fn new(segments: Vec) -> RelativeSingularQuery { RelativeSingularQuery { segments } } pub fn segments(&self) -> &[SingularQuerySegment] { &self.segments } } #[derive(Clone, Debug, PartialEq, Eq)] #[allow(dead_code)] pub enum SingularQuerySegment { Name(NameSelector), Index(IndexSelector), } hurl-7.1.0/src/jsonpath2/eval/comparison.rs000064400000000000000000000265451046102023000167650ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath2::ast::comparison::{Comparable, ComparisonExpr, ComparisonOp}; use crate::jsonpath2::ast::literal::Literal; impl ComparisonExpr { #[allow(dead_code)] pub fn eval(&self, current_value: &serde_json::Value, root_value: &serde_json::Value) -> bool { let left = self.left().eval(current_value, root_value); let right = self.right().eval(current_value, root_value); // !=, <=, >, and >= are defined in terms of the other comparison operators. match self.operator() { ComparisonOp::Equal => is_equal(&left, &right), ComparisonOp::Less => is_less(&left, &right), ComparisonOp::NotEqual => !is_equal(&left, &right), ComparisonOp::LessOrEqual => is_less(&left, &right) || is_equal(&left, &right), ComparisonOp::Greater => is_less(&right, &left), ComparisonOp::GreaterOrEqual => is_less(&right, &left) || is_equal(&left, &right), } } } fn is_equal(left: &Option, right: &Option) -> bool { left == right } fn is_less(left: &Option, right: &Option) -> bool { match (left, right) { (Some(serde_json::Value::String(left)), Some(serde_json::Value::String(right))) => { left < right } (Some(serde_json::Value::Number(left)), Some(serde_json::Value::Number(right))) => { if let (Some(left), Some(right)) = (left.as_f64(), right.as_f64()) { left < right } else { false } } _ => false, } } impl Comparable { pub fn eval( &self, current_value: &serde_json::Value, root_value: &serde_json::Value, ) -> Option { match self { Comparable::Literal(literal) => Some(literal.eval()), Comparable::SingularQuery(singular_query) => { singular_query.eval(current_value, root_value) } } } } impl Literal { pub fn eval(&self) -> serde_json::Value { match self { Literal::String(s) => serde_json::Value::String(s.clone()), Literal::Number(n) => { serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap()) } Literal::Bool(b) => serde_json::Value::Bool(*b), Literal::Null => serde_json::Value::Null, Literal::Integer(n) => serde_json::Number::from_i128(*n as i128).unwrap().into(), } } } #[cfg(test)] mod tests { use serde_json::json; use super::*; use crate::jsonpath2::ast::singular_query::SingularQuerySegment; use crate::jsonpath2::ast::{ selector::NameSelector, singular_query::{AbsoluteSingularQuery, SingularQuery}, }; fn name_query(name: &str) -> SingularQuery { SingularQuery::Absolute(AbsoluteSingularQuery::new(vec![ SingularQuerySegment::Name(NameSelector::new(name.to_string())), ])) } #[test] pub fn test_comparison() { let current_value = &serde_json::json!({}); let root_value = json!({ "obj": {"x": "y"}, "arr": [2, 3] }); // Empty nodelists assert!(ComparisonExpr::new( Comparable::SingularQuery(name_query("absent1")), Comparable::SingularQuery(name_query("absent2")), ComparisonOp::Equal ) .eval(current_value, &root_value)); // == implies <= assert!(ComparisonExpr::new( Comparable::SingularQuery(name_query("absent1")), Comparable::SingularQuery(name_query("absent2")), ComparisonOp::LessOrEqual ) .eval(current_value, &root_value)); // Empty nodelist assert!(!ComparisonExpr::new( Comparable::SingularQuery(name_query("absent")), Comparable::Literal(Literal::String("g".to_string())), ComparisonOp::Equal ) .eval(current_value, &root_value)); // Empty nodelists assert!(!ComparisonExpr::new( Comparable::SingularQuery(name_query("absent1")), Comparable::SingularQuery(name_query("absent2")), ComparisonOp::NotEqual ) .eval(current_value, &root_value)); // Empty nodelist assert!(ComparisonExpr::new( Comparable::SingularQuery(name_query("absent")), Comparable::Literal(Literal::String("g".to_string())), ComparisonOp::NotEqual ) .eval(current_value, &root_value)); // Numeric comparison assert!(ComparisonExpr::new( Comparable::Literal(Literal::Integer(1)), Comparable::Literal(Literal::Integer(2)), ComparisonOp::LessOrEqual ) .eval(current_value, &root_value)); // Numeric comparison assert!(!ComparisonExpr::new( Comparable::Literal(Literal::Integer(1)), Comparable::Literal(Literal::Integer(2)), ComparisonOp::Greater ) .eval(current_value, &root_value)); // Type mismatch assert!(!ComparisonExpr::new( Comparable::Literal(Literal::Integer(13)), Comparable::Literal(Literal::String("13".to_string())), ComparisonOp::Equal ) .eval(current_value, &root_value)); // String comparison assert!(ComparisonExpr::new( Comparable::Literal(Literal::String("a".to_string())), Comparable::Literal(Literal::String("b".to_string())), ComparisonOp::LessOrEqual ) .eval(current_value, &root_value)); // String comparison assert!(!ComparisonExpr::new( Comparable::Literal(Literal::String("a".to_string())), Comparable::Literal(Literal::String("b".to_string())), ComparisonOp::Greater ) .eval(current_value, &root_value)); // Type mismatch assert!(!ComparisonExpr::new( Comparable::SingularQuery(name_query("obj")), Comparable::SingularQuery(name_query("arr")), ComparisonOp::Equal ) .eval(current_value, &root_value)); // Type mismatch assert!(ComparisonExpr::new( Comparable::SingularQuery(name_query("obj")), Comparable::SingularQuery(name_query("arr")), ComparisonOp::NotEqual ) .eval(current_value, &root_value)); // Object comparison assert!(ComparisonExpr::new( Comparable::SingularQuery(name_query("obj")), Comparable::SingularQuery(name_query("obj")), ComparisonOp::Equal ) .eval(current_value, &root_value)); // Object comparison assert!(!ComparisonExpr::new( Comparable::SingularQuery(name_query("obj")), Comparable::SingularQuery(name_query("obj")), ComparisonOp::NotEqual ) .eval(current_value, &root_value)); // Array comparison assert!(ComparisonExpr::new( Comparable::SingularQuery(name_query("arr")), Comparable::SingularQuery(name_query("arr")), ComparisonOp::Equal ) .eval(current_value, &root_value)); // Array comparison assert!(!ComparisonExpr::new( Comparable::SingularQuery(name_query("arr")), Comparable::SingularQuery(name_query("arr")), ComparisonOp::NotEqual ) .eval(current_value, &root_value)); // Type mismatch assert!(!ComparisonExpr::new( Comparable::SingularQuery(name_query("obj")), Comparable::Literal(Literal::Integer(17)), ComparisonOp::Equal ) .eval(current_value, &root_value)); // Type mismatch assert!(ComparisonExpr::new( Comparable::SingularQuery(name_query("obj")), Comparable::Literal(Literal::Integer(17)), ComparisonOp::NotEqual ) .eval(current_value, &root_value)); // Objects and arrays do not offer < comparison assert!(!ComparisonExpr::new( Comparable::SingularQuery(name_query("obj")), Comparable::SingularQuery(name_query("arr")), ComparisonOp::LessOrEqual ) .eval(current_value, &root_value)); // Objects and arrays do not offer < comparison assert!(!ComparisonExpr::new( Comparable::SingularQuery(name_query("obj")), Comparable::SingularQuery(name_query("arr")), ComparisonOp::Less ) .eval(current_value, &root_value)); // == implies <= assert!(ComparisonExpr::new( Comparable::SingularQuery(name_query("obj")), Comparable::SingularQuery(name_query("obj")), ComparisonOp::LessOrEqual ) .eval(current_value, &root_value)); // == implies <= assert!(ComparisonExpr::new( Comparable::SingularQuery(name_query("arr")), Comparable::SingularQuery(name_query("arr")), ComparisonOp::LessOrEqual ) .eval(current_value, &root_value)); // Arrays do not offer < comparison assert!(!ComparisonExpr::new( Comparable::Literal(Literal::Integer(1)), Comparable::SingularQuery(name_query("arr")), ComparisonOp::LessOrEqual ) .eval(current_value, &root_value)); // Arrays do not offer < comparison assert!(!ComparisonExpr::new( Comparable::Literal(Literal::Integer(1)), Comparable::SingularQuery(name_query("arr")), ComparisonOp::GreaterOrEqual ) .eval(current_value, &root_value)); // Arrays do not offer < comparison assert!(!ComparisonExpr::new( Comparable::Literal(Literal::Integer(1)), Comparable::SingularQuery(name_query("arr")), ComparisonOp::Greater ) .eval(current_value, &root_value)); // Arrays do not offer < comparison assert!(!ComparisonExpr::new( Comparable::Literal(Literal::Integer(1)), Comparable::SingularQuery(name_query("arr")), ComparisonOp::Less ) .eval(current_value, &root_value)); // == implies <= assert!(ComparisonExpr::new( Comparable::Literal(Literal::Bool(true)), Comparable::Literal(Literal::Bool(true)), ComparisonOp::LessOrEqual ) .eval(current_value, &root_value)); // Booleans do not offer < comparison assert!(!ComparisonExpr::new( Comparable::Literal(Literal::Bool(true)), Comparable::Literal(Literal::Bool(true)), ComparisonOp::Greater ) .eval(current_value, &root_value)); } } hurl-7.1.0/src/jsonpath2/eval/expr.rs000064400000000000000000000147401046102023000155630ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath2::ast::expr::{AndExpr, LogicalExpr, NotExpr, OrExpr, TestExpr, TestExprKind}; impl LogicalExpr { #[allow(dead_code)] pub fn eval(&self, current_value: &serde_json::Value, root_value: &serde_json::Value) -> bool { match self { LogicalExpr::Comparison(comparison_expr) => { comparison_expr.eval(current_value, root_value) } LogicalExpr::Test(test_expr) => test_expr.eval(current_value, root_value), LogicalExpr::And(and_expr) => and_expr.eval(current_value, root_value), LogicalExpr::Or(or_expr) => or_expr.eval(current_value, root_value), LogicalExpr::Not(not_expr) => not_expr.eval(current_value, root_value), } } } impl TestExpr { /// eval the test expression to a boolean value #[allow(dead_code)] pub fn eval(&self, current_value: &serde_json::Value, root_value: &serde_json::Value) -> bool { let value = match self.kind() { TestExprKind::FilterQuery(filter_query) => { !filter_query.eval(current_value, root_value).is_empty() } TestExprKind::FunctionExpr(_function_expr) => todo!(), }; if self.not() { !value } else { value } } } impl AndExpr { /// eval and end expression to a boolean value #[allow(dead_code)] pub fn eval(&self, current_value: &serde_json::Value, root_value: &serde_json::Value) -> bool { for operand in self.operands() { if !operand.eval(current_value, root_value) { return false; } } true } } impl OrExpr { /// eval or expression to a boolean value #[allow(dead_code)] pub fn eval(&self, current_value: &serde_json::Value, root_value: &serde_json::Value) -> bool { for operand in self.operands() { if operand.eval(current_value, root_value) { return true; } } false } } impl NotExpr { /// eval not expression to a boolean value #[allow(dead_code)] pub fn eval(&self, current_value: &serde_json::Value, root_value: &serde_json::Value) -> bool { !self.expr().eval(current_value, root_value) } } #[cfg(test)] mod tests { use crate::jsonpath2::ast::comparison::{Comparable, ComparisonExpr, ComparisonOp}; use crate::jsonpath2::ast::expr::{AndExpr, LogicalExpr, OrExpr, TestExpr, TestExprKind}; use crate::jsonpath2::ast::literal::Literal; use crate::jsonpath2::ast::query::{AbsoluteQuery, Query, RelativeQuery}; use crate::jsonpath2::ast::segment::{ChildSegment, Segment}; use crate::jsonpath2::ast::selector::{NameSelector, Selector, WildcardSelector}; use crate::jsonpath2::ast::singular_query::{RelativeSingularQuery, SingularQuery}; use serde_json::json; #[test] fn test_eval_test_expr() { // @.b let test_expr = TestExpr::new( false, TestExprKind::FilterQuery(Query::RelativeQuery(RelativeQuery::new(vec![ Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "b".to_string(), ))])), ]))), ); assert!(test_expr.eval(&serde_json::json!({"b": "j"}), &serde_json::json!({}))); assert!(!test_expr.eval(&serde_json::json!(3), &serde_json::json!({}))); // $.*.name let test_expr = TestExpr::new( false, TestExprKind::FilterQuery(Query::AbsoluteQuery(AbsoluteQuery::new(vec![ Segment::Child(ChildSegment::new(vec![Selector::Wildcard( WildcardSelector, )])), Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "name".to_string(), ))])), ]))), ); assert!(test_expr.eval( &serde_json::json!({"name": "bob"}), &serde_json::json!([1, {"name": "bob"}]) )); assert!(!test_expr.eval( &serde_json::json!({"name": "bob"}), &serde_json::json!([1, 2]) )); } #[test] fn test_eval_or_expr() { // @<2 || @>4 let or_expr = LogicalExpr::Or(OrExpr::new(vec![ LogicalExpr::Comparison(ComparisonExpr::new( Comparable::SingularQuery(SingularQuery::Relative(RelativeSingularQuery::new( vec![], ))), Comparable::Literal(Literal::Integer(2)), ComparisonOp::Less, )), LogicalExpr::Comparison(ComparisonExpr::new( Comparable::SingularQuery(SingularQuery::Relative(RelativeSingularQuery::new( vec![], ))), Comparable::Literal(Literal::Integer(4)), ComparisonOp::Greater, )), ])); assert!(or_expr.eval(&json!(1), &json!([1, 2, 3, 4, 5, 6]))); assert!(!or_expr.eval(&json!(3), &json!([1, 2, 3, 4, 5, 6]))); } #[test] fn test_eval_and_expr() { // @>1 && @<4 let and_expr = LogicalExpr::And(AndExpr::new(vec![ LogicalExpr::Comparison(ComparisonExpr::new( Comparable::SingularQuery(SingularQuery::Relative(RelativeSingularQuery::new( vec![], ))), Comparable::Literal(Literal::Integer(1)), ComparisonOp::Greater, )), LogicalExpr::Comparison(ComparisonExpr::new( Comparable::SingularQuery(SingularQuery::Relative(RelativeSingularQuery::new( vec![], ))), Comparable::Literal(Literal::Integer(4)), ComparisonOp::Less, )), ])); assert!(!and_expr.eval(&json!(1), &json!([1, 2, 3, 4, 5, 6]))); assert!(and_expr.eval(&json!(2), &json!([1, 2, 3, 4, 5, 6]))); } } hurl-7.1.0/src/jsonpath2/eval/mod.rs000064400000000000000000000014131046102023000153550ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ mod comparison; mod expr; mod query; mod segment; mod selector; mod singular_query; #[allow(dead_code)] pub type NodeList = Vec; hurl-7.1.0/src/jsonpath2/eval/query.rs000064400000000000000000000055401046102023000157500ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath2::ast::query::{AbsoluteQuery, Query, RelativeQuery}; use crate::jsonpath2::eval::NodeList; impl Query { /// Eval a `Query` /// It returns a `NodeList` /// /// Note that the absolute and relative queries are not symmetrical /// An absolute query only need the root value /// while the relative query needs both the current value and the root value #[allow(dead_code)] pub fn eval( &self, current_value: &serde_json::Value, root_value: &serde_json::Value, ) -> NodeList { match self { Query::AbsoluteQuery(absolute_query) => absolute_query.eval(root_value), Query::RelativeQuery(relative_query) => relative_query.eval(current_value, root_value), } } } impl RelativeQuery { /// Eval a `RelativeQuery` for the current `serde_json::Value` input. /// It returns a `NodeList`{ pub fn eval( &self, current_value: &serde_json::Value, root_value: &serde_json::Value, ) -> NodeList { let mut results = vec![current_value.clone()]; for segment in self.segments() { results = results .iter() .flat_map(|current_value| segment.eval(current_value, root_value)) .collect(); } results } } impl AbsoluteQuery { /// Eval a JSONPath `Query` for a root `serde_json::Value` input. /// It returns a `NodeList` #[allow(dead_code)] pub fn eval(&self, root_value: &serde_json::Value) -> NodeList { let mut results = vec![root_value.clone()]; for segment in self.segments() { results = results .iter() .flat_map(|current_value| segment.eval(current_value, root_value)) .collect(); } results } } mod tests { #[allow(unused_imports)] use crate::json; #[allow(unused_imports)] use crate::jsonpath2::ast::query::AbsoluteQuery; #[allow(unused_imports)] use serde_json::json; #[test] fn test_root_identifier() { let root_value = json!({"greeting": "Hello"}); let root_identifier = AbsoluteQuery::new(vec![]); assert_eq!(root_identifier.eval(&root_value), vec![root_value]); } } hurl-7.1.0/src/jsonpath2/eval/segment.rs000064400000000000000000000125001046102023000162370ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath2::ast::segment::{ChildSegment, DescendantSegment, Segment}; use crate::jsonpath2::eval::NodeList; impl Segment { /// Eval a `Segment` for the current `serde_json::Value` input. /// It returns a `NodeList` pub fn eval( &self, current_value: &serde_json::Value, root_value: &serde_json::Value, ) -> NodeList { match self { Segment::Child(child_segment) => child_segment.eval(current_value, root_value), Segment::Descendant(descendant_segment) => { descendant_segment.eval(current_value, root_value) } } } } impl ChildSegment { /// Eval a `ChildSegment` for the current `serde_json::Value` input. /// It returns a `NodeList` pub fn eval( &self, current_value: &serde_json::Value, root_value: &serde_json::Value, ) -> NodeList { let mut results = vec![]; for selector in self.selectors() { results.append(&mut selector.eval(current_value, root_value)); } results } } impl DescendantSegment { /// Eval a `DescendantSegment` for the current `serde_json::Value` input. /// It returns a `NodeList` pub fn eval( &self, current_value: &serde_json::Value, root_value: &serde_json::Value, ) -> NodeList { let mut nodes = vec![]; for descendent in &descendants(current_value) { for selector in self.selectors() { nodes.append(&mut selector.eval(descendent, root_value)); } } nodes } } fn descendants(node: &serde_json::Value) -> NodeList { let mut nodes = vec![node.clone()]; match node { serde_json::Value::Object(map) => { for (_, value) in map { nodes.append(&mut descendants(value)); } } serde_json::Value::Array(values) => { for value in values { nodes.append(&mut descendants(value)); } } _ => {} } nodes } mod tests { #[allow(unused_imports)] use super::*; #[allow(unused_imports)] use crate::jsonpath2::ast::segment::{ChildSegment, Segment}; #[allow(unused_imports)] use crate::jsonpath2::ast::selector::{ IndexSelector, NameSelector, Selector, WildcardSelector, }; #[allow(unused_imports)] use serde_json::json; #[test] fn test_segment() { let current_value = json!({"greeting": "Hello"}); let root_value = json!("unused"); assert_eq!( Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "greeting".to_string() )),])) .eval(¤t_value, &root_value), vec![json!("Hello")] ); } #[test] fn test_child_segment() { let current_value = json!({"greeting": "Hello"}); let root_value = json!("unused"); assert_eq!( ChildSegment::new(vec![Selector::Name(NameSelector::new( "greeting".to_string() )),]) .eval(¤t_value, &root_value), vec![json!("Hello")] ); } #[test] fn test_descendant_segment() { let current_value = json!({ "o": {"j": 1, "k": 2}, "a": [5, 3, [{"j": 4}, {"k": 6}]] }); let root_value = json!("unused"); assert_eq!( DescendantSegment::new(vec![Selector::Name(NameSelector::new("j".to_string()))]) .eval(¤t_value, &root_value), vec![json!(4), json!(1),] ); assert_eq!( DescendantSegment::new(vec![Selector::Index(IndexSelector::new(0))]) .eval(¤t_value, &root_value), vec![json!(5), json!({"j": 4}),] ); assert_eq!( DescendantSegment::new(vec![Selector::Wildcard(WildcardSelector)]) .eval(¤t_value, &root_value), vec![ json!([5, 3, [{"j": 4}, {"k": 6}]]), json!({"j": 1, "k": 2}), json!(5), json!(3), json!([{"j": 4}, {"k": 6}]), json!({"j": 4}), json!({"k": 6}), json!(4), json!(6), json!(1), json!(2), ] ); } #[test] fn test_descendants() { assert_eq!(descendants(&json!("Hello")), vec![json!("Hello")]); assert_eq!( descendants(&json!([1, 2, 3])), vec![json!([1, 2, 3]), json!(1), json!(2), json!(3)] ); assert_eq!( descendants(&json!({"name": "Bob"})), vec![json!({"name": "Bob"}), json!("Bob")] ); } } hurl-7.1.0/src/jsonpath2/eval/selector.rs000064400000000000000000000251161046102023000164240ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::cmp::{max, min}; use crate::jsonpath2::ast::expr::LogicalExpr; use crate::jsonpath2::ast::selector::{ ArraySliceSelector, FilterSelector, IndexSelector, NameSelector, Selector, WildcardSelector, }; use crate::jsonpath2::eval::NodeList; impl Selector { pub fn eval( &self, current_value: &serde_json::Value, root_value: &serde_json::Value, ) -> NodeList { match self { Selector::Name(name_selector) => { name_selector.eval(current_value).into_iter().collect() } Selector::Wildcard(wildcard_selector) => wildcard_selector.eval(current_value), Selector::Index(index_selector) => { index_selector.eval(current_value).into_iter().collect() } Selector::ArraySlice(array_slice_selector) => array_slice_selector.eval(current_value), Selector::Filter(filter_selector) => filter_selector.eval(current_value, root_value), } } } impl NameSelector { pub fn eval(&self, current_value: &serde_json::Value) -> Option { if let serde_json::Value::Object(key_values) = current_value { if let Some(value) = key_values.get(self.value()) { return Some(value.clone()); } } None } } impl WildcardSelector { pub fn eval(&self, current_value: &serde_json::Value) -> NodeList { if let serde_json::Value::Object(key_values) = current_value { return key_values.values().cloned().collect::(); } else if let serde_json::Value::Array(values) = current_value { return values.to_vec(); } vec![] } } impl IndexSelector { pub fn eval(&self, current_value: &serde_json::Value) -> Option { if let serde_json::Value::Array(values) = current_value { let index = if *self.value() < 0 { values.len() - ((*self.value()).unsigned_abs() as usize) } else { *self.value() as usize }; if let Some(value) = values.get(index) { return Some(value.clone()); } } None } } impl ArraySliceSelector { pub fn eval(&self, current_value: &serde_json::Value) -> NodeList { if let serde_json::Value::Array(values) = current_value { if self.step() == 0 { return vec![]; } let len = values.len() as i32; let (lower, upper) = self.get_bounds(len); let mut results = vec![]; if self.step() > 0 { let mut i = lower; while i < upper { results.push(values.get(i as usize).unwrap().clone()); i += self.step(); } } else { let mut i = upper; while lower < i { results.push(values.get(i as usize).unwrap().clone()); i += self.step(); } } return results; } vec![] } pub fn get_start(&self, len: i32) -> i32 { if let Some(value) = self.start() { value } else if self.step() >= 0 { 0 } else { len - 1 } } pub fn get_end(&self, len: i32) -> i32 { if let Some(value) = self.end() { value } else if self.step() >= 0 { len } else { -len - 1 } } fn get_bounds(&self, len: i32) -> (i32, i32) { let n_start = normalize_index(self.get_start(len), len); let n_end = normalize_index(self.get_end(len), len); if self.step() > 0 { (min(max(n_start, 0), len), min(max(n_end, 0), len)) } else { (min(max(n_end, -1), len - 1), min(max(n_start, -1), len - 1)) } } } impl FilterSelector { pub fn eval( &self, current_value: &serde_json::Value, root_value: &serde_json::Value, ) -> NodeList { if let serde_json::Value::Object(key_values) = current_value { return key_values .values() .filter(|current_value| filter(current_value, root_value, self.expr())) .cloned() .collect::(); } else if let serde_json::Value::Array(values) = current_value { return values .iter() .filter(|current_value| filter(current_value, root_value, self.expr())) .cloned() .collect::(); } vec![] } } fn filter( current_value: &serde_json::Value, root_value: &serde_json::Value, logical_expr: &LogicalExpr, ) -> bool { logical_expr.eval(current_value, root_value) } fn normalize_index(i: i32, len: i32) -> i32 { if i >= 0 { i } else { len + i } } #[cfg(test)] mod tests { #[allow(unused_imports)] use serde_json::json; #[allow(unused_imports)] use crate::jsonpath2::ast::expr::{LogicalExpr, TestExpr, TestExprKind}; #[allow(unused_imports)] use crate::jsonpath2::ast::query::{AbsoluteQuery, Query, RelativeQuery}; #[allow(unused_imports)] use crate::jsonpath2::ast::segment::{ChildSegment, Segment}; #[allow(unused_imports)] use crate::jsonpath2::ast::selector::{ ArraySliceSelector, FilterSelector, IndexSelector, NameSelector, Selector, WildcardSelector, }; #[test] fn test_selector() { let current_value = json!({"greeting": "Hello"}); let root_value = json!("unused"); assert_eq!( Selector::Name(NameSelector::new("greeting".to_string())) .eval(¤t_value, &root_value), vec![json!("Hello")] ); } #[test] fn test_name_selector() { let current_value = json!({"greeting": "Hello"}); assert_eq!( NameSelector::new("greeting".to_string()) .eval(¤t_value) .unwrap(), json!("Hello") ); } #[test] fn test_wildcard_selector() { assert_eq!( WildcardSelector {}.eval(&json!({ "o": {"j": 1, "k": 2}, "a": [5, 3] })), vec![json!([5, 3]), json!({"j": 1, "k": 2})] ); assert_eq!( WildcardSelector {}.eval(&json!({"j": 1, "k": 2})), vec![json!(1), json!(2)] ); assert_eq!( WildcardSelector {}.eval(&json!([5, 3])), vec![json!(5), json!(3)] ); } #[test] fn test_index_selector() { let current_value = json!(["a", "b"]); assert_eq!( IndexSelector::new(1).eval(¤t_value).unwrap(), json!("b") ); assert_eq!( IndexSelector::new(-2).eval(¤t_value).unwrap(), json!("a") ); assert!(IndexSelector::new(2).eval(¤t_value).is_none()); } #[test] fn test_array_slice_selector() { let current_value = json!(["a", "b", "c", "d", "e", "f", "g"]); assert!(ArraySliceSelector::new(Some(1), Some(3), 0) .eval(¤t_value) .is_empty(),); let array_selector = ArraySliceSelector::new(Some(1), Some(3), 1); assert_eq!(array_selector.get_start(7), 1); assert_eq!(array_selector.get_end(7), 3); assert_eq!(array_selector.get_bounds(7), (1, 3)); assert_eq!( array_selector.eval(¤t_value), vec![json!("b"), json!("c")] ); let array_selector = ArraySliceSelector::new(Some(5), None, 1); assert_eq!(array_selector.get_start(7), 5); assert_eq!(array_selector.get_end(7), 7); assert_eq!(array_selector.get_bounds(7), (5, 7)); assert_eq!( array_selector.eval(¤t_value), vec![json!("f"), json!("g")] ); let array_selector = ArraySliceSelector::new(Some(1), Some(5), 2); assert_eq!(array_selector.get_start(7), 1); assert_eq!(array_selector.get_end(7), 5); assert_eq!(array_selector.get_bounds(7), (1, 5)); assert_eq!( array_selector.eval(¤t_value), vec![json!("b"), json!("d")] ); let array_selector = ArraySliceSelector::new(Some(5), Some(1), -2); assert_eq!(array_selector.get_start(7), 5); assert_eq!(array_selector.get_end(7), 1); assert_eq!(array_selector.get_bounds(7), (1, 5)); assert_eq!( array_selector.eval(¤t_value), vec![json!("f"), json!("d")] ); let array_selector = ArraySliceSelector::new(None, None, -1); assert_eq!(array_selector.get_start(7), 6); assert_eq!(array_selector.get_end(7), -8); assert_eq!(array_selector.get_bounds(7), (-1, 6)); assert_eq!( array_selector.eval(¤t_value), vec![ json!("g"), json!("f"), json!("e"), json!("d"), json!("c"), json!("b"), json!("a") ] ); } #[test] fn test_filter_selector() { let current_value = json!([3, 5, 1, 2, 4, 6, {"b": "j"}, {"b": "k"}, {"b": {}}, {"b": "kilo"} ]); // @.b let filter_selector = FilterSelector::new(LogicalExpr::Test(TestExpr::new( false, TestExprKind::FilterQuery(Query::RelativeQuery(RelativeQuery::new(vec![ Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "b".to_string(), ))])), ]))), ))); let root_value = json!({}); assert_eq!( filter_selector.eval(¤t_value, &root_value), vec![ json!({"b": "j"}), json!({"b": "k"}), json!({"b": {}}), json!({"b": "kilo"}), ] ); } } hurl-7.1.0/src/jsonpath2/eval/singular_query.rs000064400000000000000000000045511046102023000176550ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath2::ast::singular_query::{ AbsoluteSingularQuery, RelativeSingularQuery, SingularQuery, SingularQuerySegment, }; impl SingularQuery { #[allow(dead_code)] pub fn eval( &self, current_value: &serde_json::Value, root_value: &serde_json::Value, ) -> Option { match self { SingularQuery::Absolute(absolute_singular_query) => { absolute_singular_query.eval(root_value) } SingularQuery::Relative(relative_singular_query) => { relative_singular_query.eval(current_value) } } } } impl AbsoluteSingularQuery { pub fn eval(&self, value: &serde_json::Value) -> Option { let mut result = value.clone(); for segment in self.segments() { if let Some(value) = segment.eval(&result) { result = value; } else { return None; } } Some(result.clone()) } } impl RelativeSingularQuery { pub fn eval(&self, value: &serde_json::Value) -> Option { let mut result = value.clone(); for segment in self.segments() { if let Some(value) = segment.eval(&result) { result = value; } else { return None; } } Some(result.clone()) } } impl SingularQuerySegment { pub fn eval(&self, value: &serde_json::Value) -> Option { match self { SingularQuerySegment::Name(name_selector) => name_selector.eval(value), SingularQuerySegment::Index(index_selector) => index_selector.eval(value), } } } #[cfg(test)] mod tests {} hurl-7.1.0/src/jsonpath2/mod.rs000064400000000000000000000014551046102023000144340ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! JSONPath //! as defined in mod ast; mod eval; mod parser; #[allow(unused_imports)] pub use parser::parse; #[cfg(test)] mod tests; hurl-7.1.0/src/jsonpath2/parser/comparison.rs000064400000000000000000000114161046102023000173210ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath2::ast::comparison::Comparable; use crate::jsonpath2::ast::comparison::ComparisonExpr; use crate::jsonpath2::ast::comparison::ComparisonOp; use crate::jsonpath2::parser::literal; use crate::jsonpath2::parser::primitives::match_str; use crate::jsonpath2::parser::singular_query::try_parse as try_singular_query; use crate::jsonpath2::parser::ParseResult; use crate::jsonpath2::parser::{ParseError, ParseErrorKind}; use hurl_core::reader::Reader; #[allow(dead_code)] /// Try to parse a comparison expression. pub fn try_parse(reader: &mut Reader) -> ParseResult> { let save = reader.cursor(); let left = if let Some(value) = try_comparable(reader)? { value } else { return Ok(None); }; let operator = if let Some(value) = try_comparison_op(reader) { value } else { reader.seek(save); return Ok(None); }; let right = comparable(reader)?; Ok(Some(ComparisonExpr::new(left, right, operator))) } /// Parse a comparable. fn comparable(reader: &mut Reader) -> ParseResult { if let Some(value) = try_comparable(reader)? { Ok(value) } else { Err(ParseError::new( reader.cursor().pos, ParseErrorKind::Expecting("comparable".to_string()), )) } } /// Try to parse a comparable. fn try_comparable(reader: &mut Reader) -> ParseResult> { if let Ok(literal) = literal::parse(reader) { Ok(Some(Comparable::Literal(literal))) } else if let Some(singular_query) = try_singular_query(reader)? { Ok(Some(Comparable::SingularQuery(singular_query))) } else { Ok(None) } } /// Try to parse a comparison operator. /// It can not fail. Return None if no operator is found. fn try_comparison_op(reader: &mut Reader) -> Option { if match_str("==", reader) { Some(ComparisonOp::Equal) } else if match_str("!=", reader) { Some(ComparisonOp::NotEqual) } else if match_str("<=", reader) { Some(ComparisonOp::LessOrEqual) } else if match_str("<", reader) { Some(ComparisonOp::Less) } else if match_str(">=", reader) { Some(ComparisonOp::GreaterOrEqual) } else if match_str(">", reader) { Some(ComparisonOp::Greater) } else { None } } #[cfg(test)] mod tests { use crate::jsonpath2::ast::comparison::ComparisonOp; use crate::jsonpath2::ast::literal::Literal; use hurl_core::reader::{CharPos, Reader}; use super::*; #[test] pub fn test_comparison_expr() { let mut reader = Reader::new("1<=2"); assert_eq!( try_parse(&mut reader).unwrap().unwrap(), ComparisonExpr::new( Comparable::Literal(Literal::Integer(1)), Comparable::Literal(Literal::Integer(2)), ComparisonOp::LessOrEqual ) ); assert_eq!(reader.cursor().index, CharPos(4)); } #[test] pub fn test_comparison_expr_none() { // This is a test expression, not a comparison expression let mut reader = Reader::new("@.b]"); assert!(try_parse(&mut reader).unwrap().is_none()); assert_eq!(reader.cursor().index, CharPos(0)); // This is a test expression, not a comparison expression let mut reader = Reader::new("$.*.a]"); assert!(try_parse(&mut reader).unwrap().is_none()); assert_eq!(reader.cursor().index, CharPos(0)); } #[test] pub fn test_comparable() { let mut reader = Reader::new("1"); assert_eq!( comparable(&mut reader).unwrap(), Comparable::Literal(Literal::Integer(1)) ); assert_eq!(reader.cursor().index, CharPos(1)); } #[test] pub fn test_comparaison_op() { let mut reader = Reader::new("=="); assert_eq!(try_comparison_op(&mut reader).unwrap(), ComparisonOp::Equal); assert_eq!(reader.cursor().index, CharPos(2)); } #[test] pub fn test_comparaison_op_none() { let mut reader = Reader::new("]"); assert!(try_comparison_op(&mut reader).is_none()); assert_eq!(reader.cursor().index, CharPos(0)); } } hurl-7.1.0/src/jsonpath2/parser/error.rs000064400000000000000000000017371046102023000163050ustar 00000000000000use hurl_core::reader::Pos; /* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ #[allow(dead_code)] #[derive(Clone, Debug, PartialEq, Eq)] pub struct ParseError { pos: Pos, kind: ParseErrorKind, } impl ParseError { pub fn new(pos: Pos, kind: ParseErrorKind) -> Self { ParseError { pos, kind } } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum ParseErrorKind { Expecting(String), } hurl-7.1.0/src/jsonpath2/parser/expr.rs000064400000000000000000000230751046102023000161310ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath2::ast::expr::{AndExpr, LogicalExpr, NotExpr, OrExpr, TestExpr, TestExprKind}; use crate::jsonpath2::parser::comparison::try_parse as try_comparison; use crate::jsonpath2::parser::primitives::match_str; use crate::jsonpath2::parser::query::try_filter_query; use crate::jsonpath2::parser::{ParseError, ParseErrorKind, ParseResult}; use hurl_core::reader::Reader; #[allow(dead_code)] pub fn logical_or_expr(reader: &mut Reader) -> ParseResult { let mut operands = vec![]; operands.push(logical_and_expr(reader)?); // Parse additional operands separated by "||" loop { if !match_str("||", reader) { break; } operands.push(logical_or_expr(reader)?); } // If we only have one operand, return it directly if operands.len() == 1 { Ok(operands.into_iter().next().unwrap()) } else { Ok(LogicalExpr::Or(OrExpr::new(operands))) } } fn logical_and_expr(reader: &mut Reader) -> ParseResult { let mut operands = vec![]; operands.push(basic_expr(reader)?); // Parse additional operands separated by "&&" loop { if !match_str("&&", reader) { break; } operands.push(basic_expr(reader)?); } // If we only have one operand, return it directly if operands.len() == 1 { Ok(operands.into_iter().next().unwrap()) } else { Ok(LogicalExpr::And(AndExpr::new(operands))) } } fn basic_expr(reader: &mut Reader) -> ParseResult { let save = reader.cursor(); if let Some(expr) = try_paren_expr(reader)? { Ok(expr) } else if let Some(comparison_expr) = try_comparison(reader)? { Ok(LogicalExpr::Comparison(comparison_expr)) } else if let Some(test_expr) = try_test_expr(reader)? { Ok(LogicalExpr::Test(test_expr)) } else { Err(ParseError::new( save.pos, ParseErrorKind::Expecting("basic expression".to_string()), )) } } fn try_paren_expr(reader: &mut Reader) -> ParseResult> { let save = reader.cursor(); let not = match_str("!", reader); if match_str("(", reader) { let expr = logical_or_expr(reader)?; if match_str(")", reader) { let logical_expr = if not { LogicalExpr::Not(NotExpr::new(expr)) } else { expr }; Ok(Some(logical_expr)) } else { Err(ParseError::new( reader.cursor().pos, ParseErrorKind::Expecting("')'".to_string()), )) } } else { reader.seek(save); Ok(None) } } fn try_test_expr(reader: &mut Reader) -> ParseResult> { let not = match_str("!", reader); if let Some(query) = try_filter_query(reader)? { let kind = TestExprKind::FilterQuery(query); Ok(Some(TestExpr::new(not, kind))) } else { Ok(None) } } #[cfg(test)] mod tests { use super::*; use crate::jsonpath2::ast::comparison::{Comparable, ComparisonExpr, ComparisonOp}; use crate::jsonpath2::ast::expr::{AndExpr, LogicalExpr, TestExpr, TestExprKind}; use crate::jsonpath2::ast::literal::Literal; use crate::jsonpath2::ast::query::{AbsoluteQuery, Query, RelativeQuery}; use crate::jsonpath2::ast::segment::{ChildSegment, Segment}; use crate::jsonpath2::ast::selector::{NameSelector, Selector, WildcardSelector}; use crate::jsonpath2::ast::singular_query::{RelativeSingularQuery, SingularQuery}; use hurl_core::reader::Reader; #[test] fn test_parse_logical_or_expr() { let mut reader = Reader::new("@.b"); assert_eq!( logical_or_expr(&mut reader).unwrap(), LogicalExpr::Test(TestExpr::new( false, TestExprKind::FilterQuery(Query::RelativeQuery(RelativeQuery::new(vec![ Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "b".to_string() ))])) ]))) )) ); assert_eq!(reader.cursor().index, hurl_core::reader::CharPos(3)); } #[test] fn test_parse_or_expression() { let mut reader = Reader::new("@<2||@>4"); assert_eq!( logical_or_expr(&mut reader).unwrap(), LogicalExpr::Or(OrExpr::new(vec![ LogicalExpr::Comparison(ComparisonExpr::new( Comparable::SingularQuery(SingularQuery::Relative(RelativeSingularQuery::new( vec![] ))), Comparable::Literal(Literal::Integer(2)), ComparisonOp::Less )), LogicalExpr::Comparison(ComparisonExpr::new( Comparable::SingularQuery(SingularQuery::Relative(RelativeSingularQuery::new( vec![] ))), Comparable::Literal(Literal::Integer(4)), ComparisonOp::Greater )) ])) ); } #[test] fn test_parse_and_expression() { let mut reader = Reader::new("@>1&&@<4"); assert_eq!( logical_and_expr(&mut reader).unwrap(), LogicalExpr::And(AndExpr::new(vec![ LogicalExpr::Comparison(ComparisonExpr::new( Comparable::SingularQuery(SingularQuery::Relative(RelativeSingularQuery::new( vec![] ))), Comparable::Literal(Literal::Integer(1)), ComparisonOp::Greater )), LogicalExpr::Comparison(ComparisonExpr::new( Comparable::SingularQuery(SingularQuery::Relative(RelativeSingularQuery::new( vec![] ))), Comparable::Literal(Literal::Integer(4)), ComparisonOp::Less )) ])) ); } #[test] fn test_parse_paren_expression() { let mut reader = Reader::new("!(@.b)"); assert_eq!( try_paren_expr(&mut reader).unwrap().unwrap(), LogicalExpr::Not(NotExpr::new(LogicalExpr::Test(TestExpr::new( false, TestExprKind::FilterQuery(Query::RelativeQuery(RelativeQuery::new(vec![ Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "b".to_string() ))])) ]))) )))) ); assert_eq!(reader.cursor().index, hurl_core::reader::CharPos(6)); } #[test] fn test_parse_paren_expression_none() { let mut reader = Reader::new("!@.b"); assert!(try_paren_expr(&mut reader).unwrap().is_none()); assert_eq!(reader.cursor().index, hurl_core::reader::CharPos(0)); } #[test] fn test_parse_test_expr() { let mut reader = Reader::new("@.b"); assert_eq!( try_test_expr(&mut reader).unwrap().unwrap(), TestExpr::new( false, TestExprKind::FilterQuery(Query::RelativeQuery(RelativeQuery::new(vec![ Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "b".to_string() ))])) ]))) ) ); assert_eq!(reader.cursor().index, hurl_core::reader::CharPos(3)); let mut reader = Reader::new("$"); assert_eq!( try_test_expr(&mut reader).unwrap().unwrap(), TestExpr::new( false, TestExprKind::FilterQuery(Query::AbsoluteQuery(AbsoluteQuery::new(vec![]))) ) ); assert_eq!(reader.cursor().index, hurl_core::reader::CharPos(1)); let mut reader = Reader::new("$.*.a]"); assert_eq!( try_test_expr(&mut reader).unwrap().unwrap(), TestExpr::new( false, TestExprKind::FilterQuery(Query::AbsoluteQuery(AbsoluteQuery::new(vec![ Segment::Child(ChildSegment::new(vec![Selector::Wildcard( WildcardSelector )])), Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "a".to_string() ))])) ]))) ) ); assert_eq!(reader.cursor().index, hurl_core::reader::CharPos(5)); let mut reader = Reader::new("!@.a"); assert_eq!( try_test_expr(&mut reader).unwrap().unwrap(), TestExpr::new( true, TestExprKind::FilterQuery(Query::RelativeQuery(RelativeQuery::new(vec![ Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "a".to_string() ))])) ]))) ) ); assert_eq!(reader.cursor().index, hurl_core::reader::CharPos(4)); } } hurl-7.1.0/src/jsonpath2/parser/literal.rs000064400000000000000000000124451046102023000166060ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::reader::Reader; use crate::jsonpath2::ast::literal::Literal; use crate::jsonpath2::parser::{ primitives::{expect_str, match_str}, ParseError, ParseErrorKind, ParseResult, }; /// Parse a literal /// This includes standard JSON primitives (number, string, bool or null) /// with the addition of string literals with quotes // TODO: implement full spec with number parsing #[allow(dead_code)] pub fn parse(reader: &mut Reader) -> ParseResult { if try_null(reader) { Ok(Literal::Null) } else if let Some(value) = try_boolean(reader) { Ok(Literal::Bool(value)) } else if let Some(value) = try_integer(reader)? { Ok(Literal::Integer(value)) } else if let Some(value) = try_string_literal(reader)? { Ok(Literal::String(value)) } else { Err(ParseError::new( reader.cursor().pos, ParseErrorKind::Expecting("a literal".to_string()), )) } } /// Try to parse a boolean literal #[allow(dead_code)] fn try_boolean(reader: &mut Reader) -> Option { if match_str("true", reader) { Some(true) } else if match_str("false", reader) { Some(false) } else { None } } /// Try to parse a null literal #[allow(dead_code)] fn try_null(reader: &mut Reader) -> bool { match_str("null", reader) } /// Try to parse a decimal integer /// if it does not start with a minus sign or a digit /// it returns `None` rather than a `ParseError` /// // TODO: implement full spec pub fn try_integer(reader: &mut Reader) -> ParseResult> { if match_str("0", reader) { return Ok(Some(0)); } let negative = match_str("-", reader); let saved_pos = reader.cursor().pos; let s = reader.read_while(|c| c.is_ascii_digit()); if s.is_empty() || s.starts_with('0') { if negative { let kind = ParseErrorKind::Expecting("strictly positive digit".to_string()); return Err(ParseError::new(saved_pos, kind)); } else { return Ok(None); } } let sign = if negative { -1 } else { 1 }; Ok(Some(sign * s.parse::().unwrap())) } /// Try to parse a string literal /// if it does not start with a quote it returns `None` rather than a `ParseError` /// // TODO: implement full spec with double-quoted and single-quoted parser pub fn try_string_literal(reader: &mut Reader) -> ParseResult> { if match_str("\"", reader) { let s = reader.read_while(|c| c != '"'); expect_str("\"", reader)?; Ok(Some(s)) } else if match_str("'", reader) { let s = reader.read_while(|c| c != '\''); expect_str("'", reader)?; Ok(Some(s)) } else { Ok(None) } } #[cfg(test)] mod tests { use super::*; use hurl_core::reader::{CharPos, Pos, Reader}; #[test] pub fn test_literal() { let mut reader = Reader::new("null"); assert_eq!(parse(&mut reader).unwrap(), Literal::Null); assert_eq!(reader.cursor().index, CharPos(4)); let mut reader = Reader::new("true"); assert_eq!(parse(&mut reader).unwrap(), Literal::Bool(true)); assert_eq!(reader.cursor().index, CharPos(4)); } #[test] pub fn test_literal_error() { let mut reader = Reader::new("NULL"); assert_eq!( parse(&mut reader).unwrap_err(), ParseError::new( Pos::new(1, 1), ParseErrorKind::Expecting("a literal".to_string()) ) ); assert_eq!(reader.cursor().index, CharPos(0)); } #[test] fn test_string_literal() { let mut reader = Reader::new("'store'"); assert_eq!( try_string_literal(&mut reader).unwrap().unwrap(), "store".to_string() ); assert_eq!(reader.cursor().index, CharPos(7)); let mut reader = Reader::new("\"store\""); assert_eq!( try_string_literal(&mut reader).unwrap().unwrap(), "store".to_string() ); assert_eq!(reader.cursor().index, CharPos(7)); let mut reader = Reader::new("0"); assert!(try_string_literal(&mut reader).unwrap().is_none()); assert_eq!(reader.cursor().index, CharPos(0)); } #[test] fn test_string_literal_error() { let mut reader = Reader::new("'store"); assert_eq!( try_string_literal(&mut reader).unwrap_err(), ParseError::new(Pos::new(1, 7), ParseErrorKind::Expecting("'".to_string())) ); } #[test] fn test_integer() { let mut reader = Reader::new("1"); assert_eq!(try_integer(&mut reader).unwrap().unwrap(), 1); assert_eq!(reader.cursor().index, CharPos(1)); } } hurl-7.1.0/src/jsonpath2/parser/mod.rs000064400000000000000000000020551046102023000157250ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ mod comparison; mod error; mod expr; mod literal; mod primitives; mod query; mod segments; mod selectors; mod singular_query; use crate::jsonpath2::ast::JsonPathQuery; pub use error::{ParseError, ParseErrorKind}; use hurl_core::reader::Reader; pub type ParseResult = Result; #[allow(dead_code)] pub fn parse(s: &str) -> ParseResult { let mut reader = Reader::new(s); query::parse(&mut reader) } hurl-7.1.0/src/jsonpath2/parser/primitives.rs000064400000000000000000000075001046102023000173410ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ /// This module provides basic parsing functions /// which are used by other parsers. use hurl_core::reader::Reader; use super::{ParseError, ParseErrorKind, ParseResult}; /// Expect the given string `s` at the current position of the reader /// It returns a ParseError if the string does not match pub fn expect_str(s: &str, reader: &mut Reader) -> ParseResult<()> { // does not return a value // => use combinator recover to make it recoverable let start = reader.cursor(); if reader.is_eof() { let kind = ParseErrorKind::Expecting(s.to_string()); let error = ParseError::new(start.pos, kind); return Err(error); } for c in s.chars() { match reader.read() { None => { let kind = ParseErrorKind::Expecting(s.to_string()); let error = ParseError::new(start.pos, kind); return Err(error); } Some(x) => { if x != c { let kind = ParseErrorKind::Expecting(s.to_string()); let error = ParseError::new(start.pos, kind); return Err(error); } else { continue; } } } } Ok(()) } /// Try to match the given string `s` at the current position of the reader /// It returns true if the string matches, false otherwise /// If it does not match, the reader position is reset to the initial position pub fn match_str(s: &str, reader: &mut Reader) -> bool { let initial_state = reader.cursor(); if expect_str(s, reader).is_ok() { true } else { reader.seek(initial_state); false } } #[cfg(test)] mod tests { use hurl_core::reader::{CharPos, Pos}; use super::*; #[test] fn test_expect_str() { let mut reader = Reader::new("hello"); assert_eq!(expect_str("hello", &mut reader), Ok(())); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new("hello "); assert_eq!(expect_str("hello", &mut reader), Ok(())); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new(""); let error = expect_str("hello", &mut reader).err().unwrap(); assert_eq!( error, ParseError::new( Pos { line: 1, column: 1 }, ParseErrorKind::Expecting("hello".to_string()) ) ); assert_eq!(reader.cursor().index, CharPos(0)); let mut reader = Reader::new("hi"); let error = expect_str("hello", &mut reader).err().unwrap(); assert_eq!( error, ParseError::new( Pos { line: 1, column: 1 }, ParseErrorKind::Expecting("hello".to_string()) ) ); assert_eq!(reader.cursor().index, CharPos(2)); let mut reader = Reader::new("he"); let error = expect_str("hello", &mut reader).err().unwrap(); assert_eq!( error, ParseError::new( Pos { line: 1, column: 1 }, ParseErrorKind::Expecting("hello".to_string()) ) ); assert_eq!(reader.cursor().index, CharPos(2)); } } hurl-7.1.0/src/jsonpath2/parser/query.rs000064400000000000000000000124121046102023000163110ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::reader::Reader; use crate::jsonpath2::ast::query::{AbsoluteQuery, Query, RelativeQuery}; use crate::jsonpath2::parser::primitives::{expect_str, match_str}; use crate::jsonpath2::parser::{segments, ParseError, ParseErrorKind, ParseResult}; pub fn parse(reader: &mut Reader) -> ParseResult { expect_str("$", reader)?; let segments = segments::parse(reader)?; if reader.is_eof() { Ok(AbsoluteQuery::new(segments)) } else { Err(ParseError::new( reader.cursor().pos, ParseErrorKind::Expecting("end of query".to_string()), )) } } #[allow(dead_code)] pub fn try_filter_query(reader: &mut Reader) -> ParseResult> { if let Some(relative_query) = try_relative_query(reader)? { Ok(Some(Query::RelativeQuery(relative_query))) } else if let Some(absolute_query) = try_absolute_query(reader)? { Ok(Some(Query::AbsoluteQuery(absolute_query))) } else { Ok(None) } } #[allow(dead_code)] pub fn try_relative_query(reader: &mut Reader) -> ParseResult> { if match_str("@", reader) { let segments = segments::parse(reader)?; Ok(Some(RelativeQuery::new(segments))) } else { Ok(None) } } #[allow(dead_code)] pub fn try_absolute_query(reader: &mut Reader) -> ParseResult> { if match_str("$", reader) { let segments = segments::parse(reader)?; Ok(Some(AbsoluteQuery::new(segments))) } else { Ok(None) } } #[cfg(test)] mod tests { use super::super::{ParseError, ParseErrorKind}; use crate::jsonpath2::ast::expr::{LogicalExpr, TestExpr, TestExprKind}; use crate::jsonpath2::ast::query::{Query, RelativeQuery}; use crate::jsonpath2::ast::segment::{ChildSegment, Segment}; use crate::jsonpath2::ast::selector::{FilterSelector, NameSelector, Selector}; use hurl_core::reader::{CharPos, Pos, Reader}; use super::*; #[test] pub fn test_empty() { let mut reader = Reader::new(""); assert_eq!( parse(&mut reader).unwrap_err(), ParseError::new(Pos::new(1, 1), ParseErrorKind::Expecting("$".to_string())) ); assert_eq!(reader.cursor().index, CharPos(0)); } #[test] pub fn test_trailing_space() { let mut reader = Reader::new("$ "); assert_eq!( parse(&mut reader).unwrap_err(), ParseError::new( Pos::new(1, 2), ParseErrorKind::Expecting("end of query".to_string()) ) ); assert_eq!(reader.cursor().index, CharPos(1)); } #[test] pub fn test_root_identifier() { let mut reader = Reader::new("$"); assert_eq!(parse(&mut reader).unwrap(), AbsoluteQuery::new(vec![])); assert_eq!(reader.cursor().index, CharPos(1)); } #[test] pub fn test_child_segment() { let mut reader = Reader::new("$['store']"); assert_eq!( parse(&mut reader).unwrap(), AbsoluteQuery::new(vec![Segment::Child(ChildSegment::new(vec![ Selector::Name(NameSelector::new("store".to_string())) ]))]) ); assert_eq!(reader.cursor().index, CharPos(10)); let mut reader = Reader::new("$[?@['isbn']]"); assert_eq!( parse(&mut reader).unwrap(), AbsoluteQuery::new(vec![Segment::Child(ChildSegment::new(vec![ Selector::Filter(FilterSelector::new(LogicalExpr::Test(TestExpr::new( false, TestExprKind::FilterQuery(Query::RelativeQuery(RelativeQuery::new(vec![ Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "isbn".to_string() ))])) ]))) )))) ]))]) ); assert_eq!(reader.cursor().index, CharPos(13)); let mut reader = Reader::new("$.book"); assert_eq!( parse(&mut reader).unwrap(), AbsoluteQuery::new(vec![Segment::Child(ChildSegment::new(vec![ Selector::Name(NameSelector::new("book".to_string())) ]))]) ); assert_eq!(reader.cursor().index, CharPos(6)); } #[test] pub fn test_relative_query() { let mut reader = Reader::new("@['isbn']"); assert_eq!( try_relative_query(&mut reader).unwrap().unwrap(), RelativeQuery::new(vec![Segment::Child(ChildSegment::new(vec![ Selector::Name(NameSelector::new("isbn".to_string())) ]))]) ); assert_eq!(reader.cursor().index, CharPos(9)); } } hurl-7.1.0/src/jsonpath2/parser/segments.rs000064400000000000000000000242601046102023000167750ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use super::primitives::match_str; use super::ParseResult; use crate::jsonpath2::ast::segment::{ChildSegment, DescendantSegment, Segment}; use crate::jsonpath2::ast::selector::{NameSelector, Selector, WildcardSelector}; use crate::jsonpath2::parser::primitives::expect_str; use crate::jsonpath2::parser::{selectors, ParseError, ParseErrorKind}; use hurl_core::reader::Reader; /// Parse segments pub fn parse(reader: &mut Reader) -> ParseResult> { let mut segments = vec![]; while let Some(segment) = try_segment(reader)? { segments.push(segment); } Ok(segments) } /// Try to parse a segment fn try_segment(reader: &mut Reader) -> ParseResult> { if let Some(descendant_segment) = try_descendant_segment(reader)? { return Ok(Some(Segment::Descendant(descendant_segment))); } else if let Some(child_segment) = try_child_segment(reader)? { return Ok(Some(Segment::Child(child_segment))); } Ok(None) } /// Try to parse a child segment fn try_child_segment(reader: &mut Reader) -> ParseResult> { if let Some(selectors) = try_bracketed_selection(reader)? { Ok(Some(ChildSegment::new(selectors))) } else if match_str(".", reader) { let save_state = reader.cursor(); if match_str("*", reader) { Ok(Some(ChildSegment::new(vec![Selector::Wildcard( WildcardSelector, )]))) } else if let Ok(name) = member_name_shorthand(reader) { Ok(Some(ChildSegment::new(vec![Selector::Name( NameSelector::new(name), )]))) } else { Err(ParseError::new( save_state.pos, ParseErrorKind::Expecting( "a wildcard-selector or member-name shorthand".to_string(), ), )) } } else { Ok(None) } } // Try to parse a descendant segment fn try_descendant_segment(reader: &mut Reader) -> ParseResult> { if match_str("..", reader) { let save_state = reader.cursor(); if let Some(selectors) = try_bracketed_selection(reader)? { Ok(Some(DescendantSegment::new(selectors))) } else if match_str("*", reader) { Ok(Some(DescendantSegment::new(vec![Selector::Wildcard( WildcardSelector, )]))) } else if let Ok(name) = member_name_shorthand(reader) { Ok(Some(DescendantSegment::new(vec![Selector::Name( NameSelector::new(name), )]))) } else { Err(ParseError::new( save_state.pos, ParseErrorKind::Expecting( "a bracketed-selection, wildcard-selector or member-name shorthand".to_string(), ), )) } } else { Ok(None) } } fn try_bracketed_selection(reader: &mut Reader) -> ParseResult>> { if match_str("[", reader) { let selectors = selectors::parse(reader)?; expect_str("]", reader)?; Ok(Some(selectors)) } else { Ok(None) } } fn member_name_shorthand(reader: &mut Reader) -> ParseResult { let mut value = if let Some(c) = name_first(reader) { c.to_string() } else { return Err(ParseError::new( reader.cursor().pos, ParseErrorKind::Expecting("a member name".to_string()), )); }; while let Some(c) = name_char(reader) { value.push(c); } Ok(value) } fn name_first(reader: &mut Reader) -> Option { let save = reader.cursor(); if let Some(c) = reader.read() { let unicode = c as u32; if c.is_alphabetic() || c == '_' || (0x80..=0xD7FF).contains(&unicode) || (0xE000..=0x0010_FFFF).contains(&unicode) { Some(c) } else { reader.seek(save); None } } else { None } } fn name_char(reader: &mut Reader) -> Option { name_first(reader).or_else(|| digit(reader)) } fn digit(reader: &mut Reader) -> Option { let save = reader.cursor(); if let Some(c) = reader.read() { if c.is_ascii_digit() { Some(c) } else { reader.seek(save); None } } else { None } } #[cfg(test)] mod tests { use crate::jsonpath2::ast::selector::{ IndexSelector, NameSelector, Selector, WildcardSelector, }; use hurl_core::reader::{CharPos, Pos, Reader}; use super::*; #[test] pub fn test_segments() { let mut reader = Reader::new("['isbn']]"); assert_eq!( parse(&mut reader).unwrap(), vec![Segment::Child(ChildSegment::new(vec![Selector::Name( NameSelector::new("isbn".to_string()) )]))] ); assert_eq!(reader.cursor().index, CharPos(8)); } #[test] pub fn test_segment() { let mut reader = Reader::new("['store']"); assert_eq!( try_segment(&mut reader).unwrap().unwrap(), Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "store".to_string() ))])) ); assert_eq!(reader.cursor().index, CharPos(9)); } #[test] pub fn test_child_segment() { let mut reader = Reader::new("['store']"); assert_eq!( try_child_segment(&mut reader).unwrap().unwrap(), ChildSegment::new(vec![Selector::Name(NameSelector::new("store".to_string()))]) ); assert_eq!(reader.cursor().index, CharPos(9)); } #[test] pub fn test_child_segment_error() { let mut reader = Reader::new(".1"); assert_eq!( try_child_segment(&mut reader).unwrap_err(), ParseError::new( Pos::new(1, 2), ParseErrorKind::Expecting( "a wildcard-selector or member-name shorthand".to_string() ) ) ); } #[test] pub fn test_descendant_segment() { let mut reader = Reader::new("..[1]"); assert_eq!( try_descendant_segment(&mut reader).unwrap().unwrap(), DescendantSegment::new(vec![Selector::Index(IndexSelector::new(1))]) ); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new("..[1]"); assert_eq!( try_segment(&mut reader).unwrap().unwrap(), Segment::Descendant(DescendantSegment::new(vec![Selector::Index( IndexSelector::new(1) )])) ); assert_eq!(reader.cursor().index, CharPos(5)); } #[test] pub fn test_descendant_segment_error() { let mut reader = Reader::new("..1"); assert_eq!( try_descendant_segment(&mut reader).unwrap_err(), ParseError::new( Pos::new(1, 3), ParseErrorKind::Expecting( "a bracketed-selection, wildcard-selector or member-name shorthand".to_string() ) ) ); } #[test] pub fn test_bracketed_selection() { let mut reader = Reader::new("[1,'store']"); assert_eq!( try_bracketed_selection(&mut reader).unwrap().unwrap(), vec![ Selector::Index(IndexSelector::new(1)), Selector::Name(NameSelector::new("store".to_string())) ] ); assert_eq!(reader.cursor().index, CharPos(11)); } #[test] pub fn test_shorthand_notation() { let mut reader = Reader::new(".book"); assert_eq!( try_segment(&mut reader).unwrap().unwrap(), Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "book".to_string() ))])) ); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new(".☺"); assert_eq!( try_segment(&mut reader).unwrap().unwrap(), Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "☺".to_string() ))])) ); assert_eq!(reader.cursor().index, CharPos(2)); let mut reader = Reader::new("..book"); assert_eq!( try_segment(&mut reader).unwrap().unwrap(), Segment::Descendant(DescendantSegment::new(vec![Selector::Name( NameSelector::new("book".to_string()) )])) ); assert_eq!(reader.cursor().index, CharPos(6)); let mut reader = Reader::new(".*"); assert_eq!( try_segment(&mut reader).unwrap().unwrap(), Segment::Child(ChildSegment::new(vec![Selector::Wildcard( WildcardSelector )])) ); assert_eq!(reader.cursor().index, CharPos(2)); let mut reader = Reader::new("..*"); assert_eq!( try_segment(&mut reader).unwrap().unwrap(), Segment::Descendant(DescendantSegment::new(vec![Selector::Wildcard( WildcardSelector )])) ); assert_eq!(reader.cursor().index, CharPos(3)); } #[test] pub fn test_name_first() { let mut reader = Reader::new("a"); assert_eq!(name_first(&mut reader).unwrap(), 'a'); let mut reader = Reader::new("_"); assert_eq!(name_first(&mut reader).unwrap(), '_'); let mut reader = Reader::new("☺"); assert_eq!(name_first(&mut reader).unwrap(), '☺'); let mut reader = Reader::new("1"); assert!(name_first(&mut reader).is_none()); } } hurl-7.1.0/src/jsonpath2/parser/selectors.rs000064400000000000000000000223301046102023000171470ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use super::{ParseError, ParseErrorKind}; use crate::jsonpath2::ast::selector::{ ArraySliceSelector, FilterSelector, IndexSelector, NameSelector, Selector, WildcardSelector, }; use crate::jsonpath2::parser::expr::logical_or_expr; use crate::jsonpath2::parser::literal::{try_integer, try_string_literal}; use crate::jsonpath2::parser::primitives::match_str; use hurl_core::reader::Reader; use super::ParseResult; pub fn parse(reader: &mut Reader) -> ParseResult> { let mut selectors = vec![]; loop { let selector = selector(reader)?; selectors.push(selector); if !match_str(",", reader) { break; } } Ok(selectors) } pub fn selector(reader: &mut Reader) -> ParseResult { let initial_state = reader.cursor(); if let Some(name_selector) = try_name_selector(reader)? { return Ok(Selector::Name(name_selector)); } else if let Some(wildcard_selector) = try_wildcard_selector(reader) { return Ok(Selector::Wildcard(wildcard_selector)); } else if let Some(array_slice_selector) = try_array_slice_selector(reader)? { return Ok(Selector::ArraySlice(array_slice_selector)); } else if let Some(index_selector) = try_index_selector(reader)? { return Ok(Selector::Index(index_selector)); } else if let Some(filter_selector) = try_filter_selector(reader)? { return Ok(Selector::Filter(filter_selector)); } Err(ParseError::new( initial_state.pos, ParseErrorKind::Expecting("a selector".to_string()), )) } /// Try to parse a name selector pub fn try_name_selector(reader: &mut Reader) -> ParseResult> { let value = try_string_literal(reader)?; Ok(value.map(NameSelector::new)) } /// Try to parse a wildcard selector /// Returns None if it can not be parse. It can not fail. fn try_wildcard_selector(reader: &mut Reader) -> Option { if match_str("*", reader) { Some(WildcardSelector) } else { None } } /// Try to parse an index selector fn try_index_selector(reader: &mut Reader) -> ParseResult> { let value = try_integer(reader)?; Ok(value.map(IndexSelector::new)) } /// Try to parse an array_slice_selector fn try_array_slice_selector(reader: &mut Reader) -> ParseResult> { let save = reader.cursor(); let start = try_integer(reader)?; if !match_str(":", reader) { // This is not a slice-selector // but can still be a valid index or name selector reader.seek(save); return Ok(None); } let end = try_integer(reader)?; let step = if match_str(":", reader) { try_integer(reader)?.unwrap_or(1) } else { 1 }; Ok(Some(ArraySliceSelector::new(start, end, step))) } /// Try to parse a filter selector fn try_filter_selector(reader: &mut Reader) -> ParseResult> { if match_str("?", reader) { let expr = logical_or_expr(reader)?; Ok(Some(FilterSelector::new(expr))) } else { Ok(None) } } #[cfg(test)] mod tests { use super::*; use crate::jsonpath2::ast::{ comparison::{Comparable, ComparisonExpr, ComparisonOp}, expr::{LogicalExpr, TestExpr, TestExprKind}, literal::Literal, query::{Query, RelativeQuery}, segment::{ChildSegment, Segment}, singular_query::{RelativeSingularQuery, SingularQuery}, }; use hurl_core::reader::{CharPos, Reader}; #[test] pub fn test_parse() { let mut reader = Reader::new("'store',0"); assert_eq!( parse(&mut reader).unwrap(), vec![ Selector::Name(NameSelector::new("store".to_string())), Selector::Index(IndexSelector::new(0)) ] ); assert_eq!(reader.cursor().index, CharPos(9)); let mut reader = Reader::new("1,5:7"); assert_eq!( parse(&mut reader).unwrap(), vec![ Selector::Index(IndexSelector::new(1)), Selector::ArraySlice(ArraySliceSelector::new(Some(5), Some(7), 1)) ] ); assert_eq!(reader.cursor().index, CharPos(5)); } #[test] pub fn test_selector() { let mut reader = Reader::new("'store'"); assert_eq!( selector(&mut reader).unwrap(), Selector::Name(NameSelector::new("store".to_string())) ); assert_eq!(reader.cursor().index, CharPos(7)); } #[test] pub fn test_name_selector() { let mut reader = Reader::new("'store'"); assert_eq!( try_name_selector(&mut reader).unwrap().unwrap(), NameSelector::new("store".to_string()) ); assert_eq!(reader.cursor().index, CharPos(7)); } #[test] pub fn test_wildcard_selector() { let mut reader = Reader::new("*"); assert_eq!( try_wildcard_selector(&mut reader).unwrap(), WildcardSelector ); assert_eq!(reader.cursor().index, CharPos(1)); } #[test] pub fn test_index_selector() { let mut reader = Reader::new("1"); assert_eq!( try_index_selector(&mut reader).unwrap().unwrap(), IndexSelector::new(1) ); assert_eq!(reader.cursor().index, CharPos(1)); } #[test] pub fn test_array_slice_selector() { let mut reader = Reader::new("1:3"); assert_eq!( try_array_slice_selector(&mut reader).unwrap().unwrap(), ArraySliceSelector::new(Some(1), Some(3), 1) ); assert_eq!(reader.cursor().index, CharPos(3)); let mut reader = Reader::new("5:"); assert_eq!( try_array_slice_selector(&mut reader).unwrap().unwrap(), ArraySliceSelector::new(Some(5), None, 1) ); assert_eq!(reader.cursor().index, CharPos(2)); let mut reader = Reader::new("1:5:2"); assert_eq!( try_array_slice_selector(&mut reader).unwrap().unwrap(), ArraySliceSelector::new(Some(1), Some(5), 2) ); assert_eq!(reader.cursor().index, CharPos(5)); let mut reader = Reader::new("1:5:-2"); assert_eq!( try_array_slice_selector(&mut reader).unwrap().unwrap(), ArraySliceSelector::new(Some(1), Some(5), -2) ); assert_eq!(reader.cursor().index, CharPos(6)); let mut reader = Reader::new("::-1"); assert_eq!( try_array_slice_selector(&mut reader).unwrap().unwrap(), ArraySliceSelector::new(None, None, -1) ); assert_eq!(reader.cursor().index, CharPos(4)); let mut reader = Reader::new(":2"); // First 2 items assert_eq!( try_array_slice_selector(&mut reader).unwrap().unwrap(), ArraySliceSelector::new(None, Some(2), 1) ); assert_eq!(reader.cursor().index, CharPos(2)); let mut reader = Reader::new("::-1"); // Reverse items assert_eq!( try_array_slice_selector(&mut reader).unwrap().unwrap(), ArraySliceSelector::new(None, None, -1) ); assert_eq!(reader.cursor().index, CharPos(4)); let mut reader = Reader::new("2"); // This is an index selector assert!(try_array_slice_selector(&mut reader).unwrap().is_none()); assert_eq!(reader.cursor().index, CharPos(0)); let mut reader = Reader::new("?@['isbn']]"); assert!(try_array_slice_selector(&mut reader).unwrap().is_none()); } #[test] pub fn test_filter_selector() { let mut reader = Reader::new("?@['isbn']"); assert_eq!( try_filter_selector(&mut reader).unwrap().unwrap(), FilterSelector::new(LogicalExpr::Test(TestExpr::new( false, TestExprKind::FilterQuery(Query::RelativeQuery(RelativeQuery::new(vec![ Segment::Child(ChildSegment::new(vec![Selector::Name(NameSelector::new( "isbn".to_string() ))])) ]))) ))) ); assert_eq!(reader.cursor().index, CharPos(10)); let mut reader = Reader::new("?@>3]"); assert_eq!( try_filter_selector(&mut reader).unwrap().unwrap(), FilterSelector::new(LogicalExpr::Comparison(ComparisonExpr::new( Comparable::SingularQuery(SingularQuery::Relative(RelativeSingularQuery::new( vec![] ))), Comparable::Literal(Literal::Integer(3)), ComparisonOp::Greater ))) ); assert_eq!(reader.cursor().index, CharPos(4)); } } hurl-7.1.0/src/jsonpath2/parser/singular_query.rs000064400000000000000000000122211046102023000202130ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::jsonpath2::ast::selector::{IndexSelector, NameSelector}; use crate::jsonpath2::ast::singular_query::{ AbsoluteSingularQuery, RelativeSingularQuery, SingularQuery, SingularQuerySegment, }; use crate::jsonpath2::parser::literal::try_integer; use crate::jsonpath2::parser::primitives::expect_str; use crate::jsonpath2::parser::primitives::match_str; use crate::jsonpath2::parser::selectors::try_name_selector; use crate::jsonpath2::parser::ParseResult; use crate::jsonpath2::parser::{ParseError, ParseErrorKind}; use hurl_core::reader::Reader; /// Try to parse a singular query. /// It differs from a regular query in that it only matches a single node. #[allow(dead_code)] pub fn try_parse(reader: &mut Reader) -> ParseResult> { if match_str("$", reader) { let segments = singular_query_segments(reader)?; Ok(Some(SingularQuery::Absolute(AbsoluteSingularQuery::new( segments, )))) } else if match_str("@", reader) { let segments = singular_query_segments(reader)?; Ok(Some(SingularQuery::Relative(RelativeSingularQuery::new( segments, )))) } else { Ok(None) } } /// Parse singular segments. fn singular_query_segments(reader: &mut Reader) -> ParseResult> { let mut segments = vec![]; while let Some(segment) = try_singular_query_segment(reader)? { segments.push(segment); } Ok(segments) } /// Try to parse a singular query segment. fn try_singular_query_segment(reader: &mut Reader) -> ParseResult> { let save = reader.cursor(); if match_str("[", reader) { if let Some(name) = try_name_selector(reader)? { expect_str("]", reader)?; Ok(Some(SingularQuerySegment::Name(name))) } else if let Some(value) = try_integer(reader)? { expect_str("]", reader)?; Ok(Some(SingularQuerySegment::Index(IndexSelector::new(value)))) } else { Ok(None) } } else if match_str(".", reader) { match member_name_shorthand(reader) { Ok(name) => Ok(Some(SingularQuerySegment::Name(NameSelector::new(name)))), Err(_) => { reader.seek(save); Ok(None) } } } else { Ok(None) } } fn member_name_shorthand(reader: &mut Reader) -> ParseResult { let mut name = alpha(reader)?.to_string(); name.push_str(&reader.read_while(|c| c.is_alphanumeric())); Ok(name) } fn alpha(reader: &mut Reader) -> ParseResult { let pos = reader.cursor().pos; if let Some(c) = reader.read() { if c.is_alphabetic() { Ok(c) } else { let kind = ParseErrorKind::Expecting("a character".to_string()); Err(ParseError::new(pos, kind)) } } else { let kind = ParseErrorKind::Expecting("a character".to_string()); Err(ParseError::new(pos, kind)) } } #[cfg(test)] mod tests { use super::*; use crate::jsonpath2::ast::singular_query::{ AbsoluteSingularQuery, SingularQuery, SingularQuerySegment, }; use hurl_core::reader::{CharPos, Reader}; #[test] pub fn test_singular_query() { let mut reader = Reader::new("$.store"); assert_eq!( try_parse(&mut reader).unwrap().unwrap(), SingularQuery::Absolute(AbsoluteSingularQuery::new(vec![ SingularQuerySegment::Name(NameSelector::new("store".to_string())) ])) ); assert_eq!(reader.cursor().index, CharPos(7)); } #[test] pub fn test_singular_query_none() { let mut reader = Reader::new("1"); assert!(try_parse(&mut reader).unwrap().is_none()); assert_eq!(reader.cursor().index, CharPos(0)); } #[test] pub fn test_singular_query_segment() { let mut reader = Reader::new(".store"); assert_eq!( try_singular_query_segment(&mut reader).unwrap().unwrap(), SingularQuerySegment::Name(NameSelector::new("store".to_string())) ); assert_eq!(reader.cursor().index, CharPos(6)); let mut reader = Reader::new("]"); assert!(try_singular_query_segment(&mut reader).unwrap().is_none(),); assert_eq!(reader.cursor().index, CharPos(0)); } #[test] pub fn test_singular_query_segment_error() { let mut reader = Reader::new(".*"); assert!(try_singular_query_segment(&mut reader).unwrap().is_none()); assert_eq!(reader.cursor().index, CharPos(0)); } } hurl-7.1.0/src/jsonpath2/tests/cts.json000064400000000000000000006700511046102023000161410ustar 00000000000000{ "description": "JSONPath Compliance Test Suite. This file is autogenerated, do not edit.", "tests": [ { "name": "basic, root", "selector": "$", "document": [ "first", "second" ], "result": [ [ "first", "second" ] ], "result_paths": [ "$" ] }, { "name": "basic, no leading whitespace", "selector": " $", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "basic, no trailing whitespace", "selector": "$ ", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "basic, name shorthand", "selector": "$.a", "document": { "a": "A", "b": "B" }, "result": [ "A" ], "result_paths": [ "$['a']" ] }, { "name": "basic, name shorthand, extended unicode ☺", "selector": "$.☺", "document": { "☺": "A", "b": "B" }, "result": [ "A" ], "result_paths": [ "$['☺']" ] }, { "name": "basic, name shorthand, underscore", "selector": "$._", "document": { "_": "A", "_foo": "B" }, "result": [ "A" ], "result_paths": [ "$['_']" ] }, { "name": "basic, name shorthand, symbol", "selector": "$.&", "invalid_selector": true }, { "name": "basic, name shorthand, number", "selector": "$.1", "invalid_selector": true }, { "name": "basic, name shorthand, absent data", "selector": "$.c", "document": { "a": "A", "b": "B" }, "result": [], "result_paths": [] }, { "name": "basic, name shorthand, array data", "selector": "$.a", "document": [ "first", "second" ], "result": [], "result_paths": [] }, { "name": "basic, name shorthand, object data, nested", "selector": "$.a.b.c", "document": { "a": { "b": { "c": "C" } } }, "result": [ "C" ], "result_paths": [ "$['a']['b']['c']" ] }, { "name": "basic, wildcard shorthand, object data", "selector": "$.*", "document": { "a": "A", "b": "B" }, "results": [ [ "A", "B" ], [ "B", "A" ] ], "results_paths": [ [ "$['a']", "$['b']" ], [ "$['b']", "$['a']" ] ] }, { "name": "basic, wildcard shorthand, array data", "selector": "$.*", "document": [ "first", "second" ], "result": [ "first", "second" ], "result_paths": [ "$[0]", "$[1]" ] }, { "name": "basic, wildcard selector, array data", "selector": "$[*]", "document": [ "first", "second" ], "result": [ "first", "second" ], "result_paths": [ "$[0]", "$[1]" ] }, { "name": "basic, wildcard shorthand, then name shorthand", "selector": "$.*.a", "document": { "x": { "a": "Ax", "b": "Bx" }, "y": { "a": "Ay", "b": "By" } }, "results": [ [ "Ax", "Ay" ], [ "Ay", "Ax" ] ], "results_paths": [ [ "$['x']['a']", "$['y']['a']" ], [ "$['y']['a']", "$['x']['a']" ] ] }, { "name": "basic, multiple selectors", "selector": "$[0,2]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 0, 2 ], "result_paths": [ "$[0]", "$[2]" ] }, { "name": "basic, multiple selectors, space instead of comma", "selector": "$[0 2]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "basic, selector, leading comma", "selector": "$[,0]", "invalid_selector": true }, { "name": "basic, selector, trailing comma", "selector": "$[0,]", "invalid_selector": true }, { "name": "basic, multiple selectors, name and index, array data", "selector": "$['a',1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 1 ], "result_paths": [ "$[1]" ] }, { "name": "basic, multiple selectors, name and index, object data", "selector": "$['a',1]", "document": { "a": 1, "b": 2 }, "result": [ 1 ], "result_paths": [ "$['a']" ] }, { "name": "basic, multiple selectors, index and slice", "selector": "$[1,5:7]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 1, 5, 6 ], "result_paths": [ "$[1]", "$[5]", "$[6]" ] }, { "name": "basic, multiple selectors, index and slice, overlapping", "selector": "$[1,0:3]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 1, 0, 1, 2 ], "result_paths": [ "$[1]", "$[0]", "$[1]", "$[2]" ] }, { "name": "basic, multiple selectors, duplicate index", "selector": "$[1,1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 1, 1 ], "result_paths": [ "$[1]", "$[1]" ] }, { "name": "basic, multiple selectors, wildcard and index", "selector": "$[*,1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 1 ], "result_paths": [ "$[0]", "$[1]", "$[2]", "$[3]", "$[4]", "$[5]", "$[6]", "$[7]", "$[8]", "$[9]", "$[1]" ] }, { "name": "basic, multiple selectors, wildcard and name", "selector": "$[*,'a']", "document": { "a": "A", "b": "B" }, "results": [ [ "A", "B", "A" ], [ "B", "A", "A" ] ], "results_paths": [ [ "$['a']", "$['b']", "$['a']" ], [ "$['b']", "$['a']", "$['a']" ] ] }, { "name": "basic, multiple selectors, wildcard and slice", "selector": "$[*,0:2]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1 ], "result_paths": [ "$[0]", "$[1]", "$[2]", "$[3]", "$[4]", "$[5]", "$[6]", "$[7]", "$[8]", "$[9]", "$[0]", "$[1]" ] }, { "name": "basic, multiple selectors, multiple wildcards", "selector": "$[*,*]", "document": [ 0, 1, 2 ], "result": [ 0, 1, 2, 0, 1, 2 ], "result_paths": [ "$[0]", "$[1]", "$[2]", "$[0]", "$[1]", "$[2]" ] }, { "name": "basic, empty segment", "selector": "$[]", "invalid_selector": true }, { "name": "basic, descendant segment, index", "selector": "$..[1]", "document": { "o": [ 0, 1, [ 2, 3 ] ] }, "result": [ 1, 3 ], "result_paths": [ "$['o'][1]", "$['o'][2][1]" ] }, { "name": "basic, descendant segment, name shorthand", "selector": "$..a", "document": { "o": [ { "a": "b" }, { "a": "c" } ] }, "result": [ "b", "c" ], "result_paths": [ "$['o'][0]['a']", "$['o'][1]['a']" ] }, { "name": "basic, descendant segment, wildcard shorthand, array data", "selector": "$..*", "document": [ 0, 1 ], "result": [ 0, 1 ], "result_paths": [ "$[0]", "$[1]" ] }, { "name": "basic, descendant segment, wildcard selector, array data", "selector": "$..[*]", "document": [ 0, 1 ], "result": [ 0, 1 ], "result_paths": [ "$[0]", "$[1]" ] }, { "name": "basic, descendant segment, wildcard selector, nested arrays", "selector": "$..[*]", "document": [ [ [ 1 ] ], [ 2 ] ], "results": [ [ [ [ 1 ] ], [ 2 ], [ 1 ], 1, 2 ], [ [ [ 1 ] ], [ 2 ], [ 1 ], 2, 1 ] ], "results_paths": [ [ "$[0]", "$[1]", "$[0][0]", "$[0][0][0]", "$[1][0]" ], [ "$[0]", "$[1]", "$[0][0]", "$[1][0]", "$[0][0][0]" ] ] }, { "name": "basic, descendant segment, wildcard selector, nested objects", "selector": "$..[*]", "document": { "a": { "c": { "e": 1 } }, "b": { "d": 2 } }, "results": [ [ { "c": { "e": 1 } }, { "d": 2 }, { "e": 1 }, 1, 2 ], [ { "c": { "e": 1 } }, { "d": 2 }, { "e": 1 }, 2, 1 ], [ { "c": { "e": 1 } }, { "d": 2 }, 2, { "e": 1 }, 1 ], [ { "d": 2 }, { "c": { "e": 1 } }, { "e": 1 }, 1, 2 ], [ { "d": 2 }, { "c": { "e": 1 } }, { "e": 1 }, 2, 1 ], [ { "d": 2 }, { "c": { "e": 1 } }, 2, { "e": 1 }, 1 ] ], "results_paths": [ [ "$['a']", "$['b']", "$['a']['c']", "$['a']['c']['e']", "$['b']['d']" ], [ "$['a']", "$['b']", "$['a']['c']", "$['b']['d']", "$['a']['c']['e']" ], [ "$['a']", "$['b']", "$['b']['d']", "$['a']['c']", "$['a']['c']['e']" ], [ "$['b']", "$['a']", "$['a']['c']", "$['a']['c']['e']", "$['b']['d']" ], [ "$['b']", "$['a']", "$['a']['c']", "$['b']['d']", "$['a']['c']['e']" ], [ "$['b']", "$['a']", "$['b']['d']", "$['a']['c']", "$['a']['c']['e']" ] ] }, { "name": "basic, descendant segment, wildcard shorthand, object data", "selector": "$..*", "document": { "a": "b" }, "result": [ "b" ], "result_paths": [ "$['a']" ] }, { "name": "basic, descendant segment, wildcard shorthand, nested data", "selector": "$..*", "document": { "o": [ { "a": "b" } ] }, "result": [ [ { "a": "b" } ], { "a": "b" }, "b" ], "result_paths": [ "$['o']", "$['o'][0]", "$['o'][0]['a']" ] }, { "name": "basic, descendant segment, multiple selectors", "selector": "$..['a','d']", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" } ], "result": [ "b", "e", "c", "f" ], "result_paths": [ "$[0]['a']", "$[0]['d']", "$[1]['a']", "$[1]['d']" ] }, { "name": "basic, descendant segment, object traversal, multiple selectors", "selector": "$..['a','d']", "document": { "x": { "a": "b", "d": "e" }, "y": { "a": "c", "d": "f" } }, "results": [ [ "b", "e", "c", "f" ], [ "c", "f", "b", "e" ] ], "results_paths": [ [ "$['x']['a']", "$['x']['d']", "$['y']['a']", "$['y']['d']" ], [ "$['y']['a']", "$['y']['d']", "$['x']['a']", "$['x']['d']" ] ] }, { "name": "basic, bald descendant segment", "selector": "$..", "invalid_selector": true }, { "name": "basic, current node identifier without filter selector", "selector": "$[@.a]", "invalid_selector": true }, { "name": "basic, root node identifier in brackets without filter selector", "selector": "$[$.a]", "invalid_selector": true }, { "name": "filter, existence, without segments", "selector": "$[?@]", "document": { "a": 1, "b": null }, "results": [ [ 1, null ], [ null, 1 ] ], "results_paths": [ [ "$['a']", "$['b']" ], [ "$['b']", "$['a']" ] ] }, { "name": "filter, existence", "selector": "$[?@.a]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, existence, present with null", "selector": "$[?@.a]", "document": [ { "a": null, "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": null, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, absolute existence, without segments", "selector": "$[?$]", "document": { "a": 1, "b": null }, "results": [ [ 1, null ], [ null, 1 ] ], "results_paths": [ [ "$['a']", "$['b']" ], [ "$['b']", "$['a']" ] ] }, { "name": "filter, absolute existence, with segments", "selector": "$[?$.*.a]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result_paths": [ "$[0]", "$[1]" ] }, { "name": "filter, equals string, single quotes", "selector": "$[?@.a=='b']", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals numeric string, single quotes", "selector": "$[?@.a=='1']", "document": [ { "a": "1", "d": "e" }, { "a": 1, "d": "f" } ], "result": [ { "a": "1", "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals string, double quotes", "selector": "$[?@.a==\"b\"]", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals numeric string, double quotes", "selector": "$[?@.a==\"1\"]", "document": [ { "a": "1", "d": "e" }, { "a": 1, "d": "f" } ], "result": [ { "a": "1", "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number", "selector": "$[?@.a==1]", "document": [ { "a": 1, "d": "e" }, { "a": "c", "d": "f" }, { "a": 2, "d": "f" }, { "a": "1", "d": "f" } ], "result": [ { "a": 1, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals null", "selector": "$[?@.a==null]", "document": [ { "a": null, "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": null, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals null, absent from data", "selector": "$[?@.a==null]", "document": [ { "d": "e" }, { "a": "c", "d": "f" } ], "result": [], "result_paths": [] }, { "name": "filter, equals true", "selector": "$[?@.a==true]", "document": [ { "a": true, "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": true, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals false", "selector": "$[?@.a==false]", "document": [ { "a": false, "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": false, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals self", "selector": "$[?@==@]", "document": [ 1, null, true, { "a": "b" }, [ false ] ], "result": [ 1, null, true, { "a": "b" }, [ false ] ], "result_paths": [ "$[0]", "$[1]", "$[2]", "$[3]", "$[4]" ] }, { "name": "filter, absolute, equals self", "selector": "$[?$==$]", "document": [ 1, null, true, { "a": "b" }, [ false ] ], "result": [ 1, null, true, { "a": "b" }, [ false ] ], "result_paths": [ "$[0]", "$[1]", "$[2]", "$[3]", "$[4]" ] }, { "name": "filter, equals, absent from index selector equals absent from name selector", "selector": "$[?@.absent==@.list[9]]", "document": [ { "list": [ 1 ] } ], "result": [ { "list": [ 1 ] } ], "result_paths": [ "$[0]" ] }, { "name": "filter, deep equality, arrays", "selector": "$[?@.a==@.b]", "document": [ { "a": false, "b": [ 1, 2 ] }, { "a": [ [ 1, [ 2 ] ] ], "b": [ [ 1, [ 2 ] ] ] }, { "a": [ [ 1, [ 2 ] ] ], "b": [ [ [ 2 ], 1 ] ] }, { "a": [ [ 1, [ 2 ] ] ], "b": [ [ 1, 2 ] ] } ], "result": [ { "a": [ [ 1, [ 2 ] ] ], "b": [ [ 1, [ 2 ] ] ] } ], "result_paths": [ "$[1]" ] }, { "name": "filter, deep equality, objects", "selector": "$[?@.a==@.b]", "document": [ { "a": false, "b": { "x": 1, "y": { "z": 1 } } }, { "a": { "x": 1, "y": { "z": 1 } }, "b": { "x": 1, "y": { "z": 1 } } }, { "a": { "x": 1, "y": { "z": 1 } }, "b": { "y": { "z": 1 }, "x": 1 } }, { "a": { "x": 1, "y": { "z": 1 } }, "b": { "x": 1 } }, { "a": { "x": 1, "y": { "z": 1 } }, "b": { "x": 1, "y": { "z": 2 } } } ], "result": [ { "a": { "x": 1, "y": { "z": 1 } }, "b": { "x": 1, "y": { "z": 1 } } }, { "a": { "x": 1, "y": { "z": 1 } }, "b": { "y": { "z": 1 }, "x": 1 } } ], "result_paths": [ "$[1]", "$[2]" ] }, { "name": "filter, not-equals string, single quotes", "selector": "$[?@.a!='b']", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "c", "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, not-equals numeric string, single quotes", "selector": "$[?@.a!='1']", "document": [ { "a": "1", "d": "e" }, { "a": 1, "d": "f" } ], "result": [ { "a": 1, "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, not-equals string, single quotes, different type", "selector": "$[?@.a!='b']", "document": [ { "a": "b", "d": "e" }, { "a": 1, "d": "f" } ], "result": [ { "a": 1, "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, not-equals string, double quotes", "selector": "$[?@.a!=\"b\"]", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "c", "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, not-equals numeric string, double quotes", "selector": "$[?@.a!=\"1\"]", "document": [ { "a": "1", "d": "e" }, { "a": 1, "d": "f" } ], "result": [ { "a": 1, "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, not-equals string, double quotes, different types", "selector": "$[?@.a!=\"b\"]", "document": [ { "a": "b", "d": "e" }, { "a": 1, "d": "f" } ], "result": [ { "a": 1, "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, not-equals number", "selector": "$[?@.a!=1]", "document": [ { "a": 1, "d": "e" }, { "a": 2, "d": "f" }, { "a": "1", "d": "f" } ], "result": [ { "a": 2, "d": "f" }, { "a": "1", "d": "f" } ], "result_paths": [ "$[1]", "$[2]" ] }, { "name": "filter, not-equals number, different types", "selector": "$[?@.a!=1]", "document": [ { "a": 1, "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "c", "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, not-equals null", "selector": "$[?@.a!=null]", "document": [ { "a": null, "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "c", "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, not-equals null, absent from data", "selector": "$[?@.a!=null]", "document": [ { "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "d": "e" }, { "a": "c", "d": "f" } ], "result_paths": [ "$[0]", "$[1]" ] }, { "name": "filter, not-equals true", "selector": "$[?@.a!=true]", "document": [ { "a": true, "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "c", "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, not-equals false", "selector": "$[?@.a!=false]", "document": [ { "a": false, "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "c", "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, less than string, single quotes", "selector": "$[?@.a<'c']", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, less than string, double quotes", "selector": "$[?@.a<\"c\"]", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, less than number", "selector": "$[?@.a<10]", "document": [ { "a": 1, "d": "e" }, { "a": 10, "d": "e" }, { "a": "c", "d": "f" }, { "a": 20, "d": "f" } ], "result": [ { "a": 1, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, less than null", "selector": "$[?@.a'c']", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "a": "d", "d": "f" } ], "result_paths": [ "$[2]" ] }, { "name": "filter, greater than string, double quotes", "selector": "$[?@.a>\"c\"]", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "a": "d", "d": "f" } ], "result_paths": [ "$[2]" ] }, { "name": "filter, greater than number", "selector": "$[?@.a>10]", "document": [ { "a": 1, "d": "e" }, { "a": 10, "d": "e" }, { "a": "c", "d": "f" }, { "a": 20, "d": "f" } ], "result": [ { "a": 20, "d": "f" } ], "result_paths": [ "$[3]" ] }, { "name": "filter, greater than null", "selector": "$[?@.a>null]", "document": [ { "a": null, "d": "e" }, { "a": "c", "d": "f" } ], "result": [], "result_paths": [] }, { "name": "filter, greater than true", "selector": "$[?@.a>true]", "document": [ { "a": true, "d": "e" }, { "a": "c", "d": "f" } ], "result": [], "result_paths": [] }, { "name": "filter, greater than false", "selector": "$[?@.a>false]", "document": [ { "a": false, "d": "e" }, { "a": "c", "d": "f" } ], "result": [], "result_paths": [] }, { "name": "filter, greater than or equal to string, single quotes", "selector": "$[?@.a>='c']", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "a": "c", "d": "f" }, { "a": "d", "d": "f" } ], "result_paths": [ "$[1]", "$[2]" ] }, { "name": "filter, greater than or equal to string, double quotes", "selector": "$[?@.a>=\"c\"]", "document": [ { "a": "b", "d": "e" }, { "a": "c", "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "a": "c", "d": "f" }, { "a": "d", "d": "f" } ], "result_paths": [ "$[1]", "$[2]" ] }, { "name": "filter, greater than or equal to number", "selector": "$[?@.a>=10]", "document": [ { "a": 1, "d": "e" }, { "a": 10, "d": "e" }, { "a": "c", "d": "f" }, { "a": 20, "d": "f" } ], "result": [ { "a": 10, "d": "e" }, { "a": 20, "d": "f" } ], "result_paths": [ "$[1]", "$[3]" ] }, { "name": "filter, greater than or equal to null", "selector": "$[?@.a>=null]", "document": [ { "a": null, "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": null, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, greater than or equal to true", "selector": "$[?@.a>=true]", "document": [ { "a": true, "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": true, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, greater than or equal to false", "selector": "$[?@.a>=false]", "document": [ { "a": false, "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": false, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, exists and not-equals null, absent from data", "selector": "$[?@.a&&@.a!=null]", "document": [ { "d": "e" }, { "a": "c", "d": "f" } ], "result": [ { "a": "c", "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, exists and exists, data false", "selector": "$[?@.a&&@.b]", "document": [ { "a": false, "b": false }, { "b": false }, { "c": false } ], "result": [ { "a": false, "b": false } ], "result_paths": [ "$[0]" ] }, { "name": "filter, exists or exists, data false", "selector": "$[?@.a||@.b]", "document": [ { "a": false, "b": false }, { "b": false }, { "c": false } ], "result": [ { "a": false, "b": false }, { "b": false } ], "result_paths": [ "$[0]", "$[1]" ] }, { "name": "filter, and", "selector": "$[?@.a>0&&@.a<10]", "document": [ { "a": -10, "d": "e" }, { "a": 5, "d": "f" }, { "a": 20, "d": "f" } ], "result": [ { "a": 5, "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, or", "selector": "$[?@.a=='b'||@.a=='d']", "document": [ { "a": "a", "d": "e" }, { "a": "b", "d": "f" }, { "a": "c", "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "a": "b", "d": "f" }, { "a": "d", "d": "f" } ], "result_paths": [ "$[1]", "$[3]" ] }, { "name": "filter, not expression", "selector": "$[?!(@.a=='b')]", "document": [ { "a": "a", "d": "e" }, { "a": "b", "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "a": "a", "d": "e" }, { "a": "d", "d": "f" } ], "result_paths": [ "$[0]", "$[2]" ] }, { "name": "filter, not exists", "selector": "$[?!@.a]", "document": [ { "a": "a", "d": "e" }, { "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, not exists, data null", "selector": "$[?!@.a]", "document": [ { "a": null, "d": "e" }, { "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "d": "f" } ], "result_paths": [ "$[1]" ] }, { "name": "filter, non-singular existence, wildcard", "selector": "$[?@.*]", "document": [ 1, [], [ 2 ], {}, { "a": 3 } ], "result": [ [ 2 ], { "a": 3 } ], "result_paths": [ "$[2]", "$[4]" ] }, { "name": "filter, non-singular existence, multiple", "selector": "$[?@[0, 0, 'a']]", "document": [ 1, [], [ 2 ], [ 2, 3 ], { "a": 3 }, { "b": 4 }, { "a": 3, "b": 4 } ], "result": [ [ 2 ], [ 2, 3 ], { "a": 3 }, { "a": 3, "b": 4 } ], "result_paths": [ "$[2]", "$[3]", "$[4]", "$[6]" ] }, { "name": "filter, non-singular existence, slice", "selector": "$[?@[0:2]]", "document": [ 1, [], [ 2 ], [ 2, 3, 4 ], {}, { "a": 3 } ], "result": [ [ 2 ], [ 2, 3, 4 ] ], "result_paths": [ "$[2]", "$[3]" ] }, { "name": "filter, non-singular existence, negated", "selector": "$[?!@.*]", "document": [ 1, [], [ 2 ], {}, { "a": 3 } ], "result": [ 1, [], {} ], "result_paths": [ "$[0]", "$[1]", "$[3]" ] }, { "name": "filter, non-singular query in comparison, slice", "selector": "$[?@[0:0]==0]", "invalid_selector": true }, { "name": "filter, non-singular query in comparison, all children", "selector": "$[?@[*]==0]", "invalid_selector": true }, { "name": "filter, non-singular query in comparison, descendants", "selector": "$[?@..a==0]", "invalid_selector": true }, { "name": "filter, non-singular query in comparison, combined", "selector": "$[?@.a[*].a==0]", "invalid_selector": true }, { "name": "filter, nested", "selector": "$[?@[?@>1]]", "document": [ [ 0 ], [ 0, 1 ], [ 0, 1, 2 ], [ 42 ] ], "result": [ [ 0, 1, 2 ], [ 42 ] ], "result_paths": [ "$[2]", "$[3]" ] }, { "name": "filter, name segment on primitive, selects nothing", "selector": "$[?@.a == 1]", "document": { "a": 1 }, "result": [], "result_paths": [] }, { "name": "filter, name segment on array, selects nothing", "selector": "$[?@['0'] == 5]", "document": [ [ 5, 6 ] ], "result": [], "result_paths": [] }, { "name": "filter, index segment on object, selects nothing", "selector": "$[?@[0] == 5]", "document": [ { "0": 5 } ], "result": [], "result_paths": [] }, { "name": "filter, followed by name selector", "selector": "$[?@.a==1].b.x", "document": [ { "a": 1, "b": { "x": 2 } } ], "result": [ 2 ], "result_paths": [ "$[0]['b']['x']" ] }, { "name": "filter, followed by child segment that selects multiple elements", "selector": "$[?@.z=='_']['x','y']", "document": [ { "x": 1, "y": null, "z": "_" } ], "result": [ 1, null ], "result_paths": [ "$[0]['x']", "$[0]['y']" ] }, { "name": "filter, relative non-singular query, index, equal", "selector": "$[?(@[0, 0]==42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, relative non-singular query, index, not equal", "selector": "$[?(@[0, 0]!=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, relative non-singular query, index, less-or-equal", "selector": "$[?(@[0, 0]<=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, relative non-singular query, name, equal", "selector": "$[?(@['a', 'a']==42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, relative non-singular query, name, not equal", "selector": "$[?(@['a', 'a']!=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, relative non-singular query, name, less-or-equal", "selector": "$[?(@['a', 'a']<=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, relative non-singular query, combined, equal", "selector": "$[?(@[0, '0']==42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, relative non-singular query, combined, not equal", "selector": "$[?(@[0, '0']!=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, relative non-singular query, combined, less-or-equal", "selector": "$[?(@[0, '0']<=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, relative non-singular query, wildcard, equal", "selector": "$[?(@.*==42)]", "invalid_selector": true }, { "name": "filter, relative non-singular query, wildcard, not equal", "selector": "$[?(@.*!=42)]", "invalid_selector": true }, { "name": "filter, relative non-singular query, wildcard, less-or-equal", "selector": "$[?(@.*<=42)]", "invalid_selector": true }, { "name": "filter, relative non-singular query, slice, equal", "selector": "$[?(@[0:0]==42)]", "invalid_selector": true }, { "name": "filter, relative non-singular query, slice, not equal", "selector": "$[?(@[0:0]!=42)]", "invalid_selector": true }, { "name": "filter, relative non-singular query, slice, less-or-equal", "selector": "$[?(@[0:0]<=42)]", "invalid_selector": true }, { "name": "filter, absolute non-singular query, index, equal", "selector": "$[?($[0, 0]==42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, absolute non-singular query, index, not equal", "selector": "$[?($[0, 0]!=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, absolute non-singular query, index, less-or-equal", "selector": "$[?($[0, 0]<=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, absolute non-singular query, name, equal", "selector": "$[?($['a', 'a']==42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, absolute non-singular query, name, not equal", "selector": "$[?($['a', 'a']!=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, absolute non-singular query, name, less-or-equal", "selector": "$[?($['a', 'a']<=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, absolute non-singular query, combined, equal", "selector": "$[?($[0, '0']==42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, absolute non-singular query, combined, not equal", "selector": "$[?($[0, '0']!=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, absolute non-singular query, combined, less-or-equal", "selector": "$[?($[0, '0']<=42)]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, absolute non-singular query, wildcard, equal", "selector": "$[?($.*==42)]", "invalid_selector": true }, { "name": "filter, absolute non-singular query, wildcard, not equal", "selector": "$[?($.*!=42)]", "invalid_selector": true }, { "name": "filter, absolute non-singular query, wildcard, less-or-equal", "selector": "$[?($.*<=42)]", "invalid_selector": true }, { "name": "filter, absolute non-singular query, slice, equal", "selector": "$[?($[0:0]==42)]", "invalid_selector": true }, { "name": "filter, absolute non-singular query, slice, not equal", "selector": "$[?($[0:0]!=42)]", "invalid_selector": true }, { "name": "filter, absolute non-singular query, slice, less-or-equal", "selector": "$[?($[0:0]<=42)]", "invalid_selector": true }, { "name": "filter, multiple selectors", "selector": "$[?@.a,?@.b]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result_paths": [ "$[0]", "$[1]" ] }, { "name": "filter, multiple selectors, comparison", "selector": "$[?@.a=='b',?@.b=='x']", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, multiple selectors, overlapping", "selector": "$[?@.a,?@.d]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" }, { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result_paths": [ "$[0]", "$[0]", "$[1]" ] }, { "name": "filter, multiple selectors, filter and index", "selector": "$[?@.a,1]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result_paths": [ "$[0]", "$[1]" ] }, { "name": "filter, multiple selectors, filter and wildcard", "selector": "$[?@.a,*]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" }, { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result_paths": [ "$[0]", "$[0]", "$[1]" ] }, { "name": "filter, multiple selectors, filter and slice", "selector": "$[?@.a,1:]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" }, { "g": "h" } ], "result": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" }, { "g": "h" } ], "result_paths": [ "$[0]", "$[1]", "$[2]" ] }, { "name": "filter, multiple selectors, comparison filter, index and slice", "selector": "$[1, ?@.a=='b', 1:]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "b": "c", "d": "f" }, { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result_paths": [ "$[1]", "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "filter, equals number, zero and negative zero", "selector": "$[?@.a==0]", "document": [ { "a": 0, "d": "e" }, { "a": 0.1, "d": "f" }, { "a": "0", "d": "g" } ], "result": [ { "a": 0, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, negative zero and zero", "selector": "$[?@.a==-0]", "document": [ { "a": 0, "d": "e" }, { "a": 0.1, "d": "f" }, { "a": "0", "d": "g" } ], "result": [ { "a": 0, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, with and without decimal fraction", "selector": "$[?@.a==1.0]", "document": [ { "a": 1, "d": "e" }, { "a": 2, "d": "f" }, { "a": "1", "d": "g" } ], "result": [ { "a": 1, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, exponent", "selector": "$[?@.a==1e2]", "document": [ { "a": 100, "d": "e" }, { "a": 100.1, "d": "f" }, { "a": "100", "d": "g" } ], "result": [ { "a": 100, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, exponent upper e", "selector": "$[?@.a==1E2]", "document": [ { "a": 100, "d": "e" }, { "a": 100.1, "d": "f" }, { "a": "100", "d": "g" } ], "result": [ { "a": 100, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, positive exponent", "selector": "$[?@.a==1e+2]", "document": [ { "a": 100, "d": "e" }, { "a": 100.1, "d": "f" }, { "a": "100", "d": "g" } ], "result": [ { "a": 100, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, negative exponent", "selector": "$[?@.a==1e-2]", "document": [ { "a": 0.01, "d": "e" }, { "a": 0.02, "d": "f" }, { "a": "0.01", "d": "g" } ], "result": [ { "a": 0.01, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, exponent 0", "selector": "$[?@.a==1e0]", "document": [ { "a": 1, "d": "e" }, { "a": 2, "d": "f" }, { "a": "1", "d": "g" } ], "result": [ { "a": 1, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, exponent -0", "selector": "$[?@.a==1e-0]", "document": [ { "a": 1, "d": "e" }, { "a": 2, "d": "f" }, { "a": "1", "d": "g" } ], "result": [ { "a": 1, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, exponent +0", "selector": "$[?@.a==1e+0]", "document": [ { "a": 1, "d": "e" }, { "a": 2, "d": "f" }, { "a": "1", "d": "g" } ], "result": [ { "a": 1, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, exponent leading -0", "selector": "$[?@.a==1e-02]", "document": [ { "a": 0.01, "d": "e" }, { "a": 0.02, "d": "f" }, { "a": "0.01", "d": "g" } ], "result": [ { "a": 0.01, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, exponent +00", "selector": "$[?@.a==1e+00]", "document": [ { "a": 1, "d": "e" }, { "a": 2, "d": "f" }, { "a": "1", "d": "g" } ], "result": [ { "a": 1, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, decimal fraction", "selector": "$[?@.a==1.1]", "document": [ { "a": 1.1, "d": "e" }, { "a": 1, "d": "f" }, { "a": "1.1", "d": "g" } ], "result": [ { "a": 1.1, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, decimal fraction, trailing 0", "selector": "$[?@.a==1.10]", "document": [ { "a": 1.1, "d": "e" }, { "a": 1, "d": "f" }, { "a": "1.1", "d": "g" } ], "result": [ { "a": 1.1, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, decimal fraction, exponent", "selector": "$[?@.a==1.1e2]", "document": [ { "a": 110, "d": "e" }, { "a": 110.1, "d": "f" }, { "a": "110", "d": "g" } ], "result": [ { "a": 110, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, decimal fraction, positive exponent", "selector": "$[?@.a==1.1e+2]", "document": [ { "a": 110, "d": "e" }, { "a": 110.1, "d": "f" }, { "a": "110", "d": "g" } ], "result": [ { "a": 110, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, decimal fraction, negative exponent", "selector": "$[?@.a==1.1e-2]", "document": [ { "a": 0.011, "d": "e" }, { "a": 0.012, "d": "f" }, { "a": "0.011", "d": "g" } ], "result": [ { "a": 0.011, "d": "e" } ], "result_paths": [ "$[0]" ] }, { "name": "filter, equals number, invalid plus", "selector": "$[?@.a==+1]", "invalid_selector": true }, { "name": "filter, equals number, invalid minus space", "selector": "$[?@.a==- 1]", "invalid_selector": true }, { "name": "filter, equals number, invalid double minus", "selector": "$[?@.a==--1]", "invalid_selector": true }, { "name": "filter, equals number, invalid no int digit", "selector": "$[?@.a==.1]", "invalid_selector": true }, { "name": "filter, equals number, invalid minus no int digit", "selector": "$[?@.a==-.1]", "invalid_selector": true }, { "name": "filter, equals number, invalid 00", "selector": "$[?@.a==00]", "invalid_selector": true }, { "name": "filter, equals number, invalid leading 0", "selector": "$[?@.a==01]", "invalid_selector": true }, { "name": "filter, equals number, invalid no fractional digit", "selector": "$[?@.a==1.]", "invalid_selector": true }, { "name": "filter, equals number, invalid middle minus", "selector": "$[?@.a==1.-1]", "invalid_selector": true }, { "name": "filter, equals number, invalid no fractional digit e", "selector": "$[?@.a==1.e1]", "invalid_selector": true }, { "name": "filter, equals number, invalid no e digit", "selector": "$[?@.a==1e]", "invalid_selector": true }, { "name": "filter, equals number, invalid no e digit minus", "selector": "$[?@.a==1e-]", "invalid_selector": true }, { "name": "filter, equals number, invalid double e", "selector": "$[?@.a==1eE1]", "invalid_selector": true }, { "name": "filter, equals number, invalid e digit double minus", "selector": "$[?@.a==1e--1]", "invalid_selector": true }, { "name": "filter, equals number, invalid e digit plus minus", "selector": "$[?@.a==1e+-1]", "invalid_selector": true }, { "name": "filter, equals number, invalid e decimal", "selector": "$[?@.a==1e2.3]", "invalid_selector": true }, { "name": "filter, equals number, invalid multi e", "selector": "$[?@.a==1e2e3]", "invalid_selector": true }, { "name": "filter, equals, special nothing", "selector": "$.values[?length(@.a) == value($..c)]", "document": { "c": "cd", "values": [ { "a": "ab" }, { "c": "d" }, { "a": null } ] }, "result": [ { "c": "d" }, { "a": null } ], "result_paths": [ "$['values'][1]", "$['values'][2]" ], "tags": [ "function" ] }, { "name": "filter, equals, empty node list and empty node list", "selector": "$[?@.a == @.b]", "document": [ { "a": 1 }, { "b": 2 }, { "c": 3 } ], "result": [ { "c": 3 } ], "result_paths": [ "$[2]" ] }, { "name": "filter, equals, empty node list and special nothing", "selector": "$[?@.a == length(@.b)]", "document": [ { "a": 1 }, { "b": 2 }, { "c": 3 } ], "result": [ { "b": 2 }, { "c": 3 } ], "result_paths": [ "$[1]", "$[2]" ], "tags": [ "function", "whitespace" ] }, { "name": "filter, object data", "selector": "$[?@<3]", "document": { "a": 1, "b": 2, "c": 3 }, "results": [ [ 1, 2 ], [ 2, 1 ] ], "results_paths": [ [ "$['a']", "$['b']" ], [ "$['b']", "$['a']" ] ] }, { "name": "filter, and binds more tightly than or", "selector": "$[?@.a || @.b && @.c]", "document": [ { "a": 1 }, { "b": 2, "c": 3 }, { "c": 3 }, { "b": 2 }, { "a": 1, "b": 2, "c": 3 } ], "result": [ { "a": 1 }, { "b": 2, "c": 3 }, { "a": 1, "b": 2, "c": 3 } ], "result_paths": [ "$[0]", "$[1]", "$[4]" ], "tags": [ "whitespace" ] }, { "name": "filter, left to right evaluation", "selector": "$[?@.a && @.b || @.c]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 1, "b": 2 }, { "a": 1, "c": 3 }, { "b": 1, "c": 3 }, { "c": 3 }, { "a": 1, "b": 2, "c": 3 } ], "result": [ { "a": 1, "b": 2 }, { "a": 1, "c": 3 }, { "b": 1, "c": 3 }, { "c": 3 }, { "a": 1, "b": 2, "c": 3 } ], "result_paths": [ "$[2]", "$[3]", "$[4]", "$[5]", "$[6]" ], "tags": [ "whitespace" ] }, { "name": "filter, group terms, left", "selector": "$[?(@.a || @.b) && @.c]", "document": [ { "a": 1, "b": 2 }, { "a": 1, "c": 3 }, { "b": 2, "c": 3 }, { "a": 1 }, { "b": 2 }, { "c": 3 }, { "a": 1, "b": 2, "c": 3 } ], "result": [ { "a": 1, "c": 3 }, { "b": 2, "c": 3 }, { "a": 1, "b": 2, "c": 3 } ], "result_paths": [ "$[1]", "$[2]", "$[6]" ], "tags": [ "whitespace" ] }, { "name": "filter, group terms, right", "selector": "$[?@.a && (@.b || @.c)]", "document": [ { "a": 1 }, { "a": 1, "b": 2 }, { "a": 1, "c": 2 }, { "b": 2 }, { "c": 2 }, { "a": 1, "b": 2, "c": 3 } ], "result": [ { "a": 1, "b": 2 }, { "a": 1, "c": 2 }, { "a": 1, "b": 2, "c": 3 } ], "result_paths": [ "$[1]", "$[2]", "$[5]" ], "tags": [ "whitespace" ] }, { "name": "filter, string literal, single quote in double quotes", "selector": "$[?@ == \"quoted' literal\"]", "document": [ "quoted' literal", "a", "quoted\\' literal" ], "result": [ "quoted' literal" ], "result_paths": [ "$[0]" ] }, { "name": "filter, string literal, double quote in single quotes", "selector": "$[?@ == 'quoted\" literal']", "document": [ "quoted\" literal", "a", "quoted\\\" literal", "'quoted\" literal'" ], "result": [ "quoted\" literal" ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "filter, string literal, escaped single quote in single quotes", "selector": "$[?@ == 'quoted\\' literal']", "document": [ "quoted' literal", "a", "quoted\\' literal", "'quoted\" literal'" ], "result": [ "quoted' literal" ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "filter, string literal, escaped double quote in double quotes", "selector": "$[?@ == \"quoted\\\" literal\"]", "document": [ "quoted\" literal", "a", "quoted\\\" literal", "'quoted\" literal'" ], "result": [ "quoted\" literal" ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "filter, literal true must be compared", "selector": "$[?true]", "invalid_selector": true }, { "name": "filter, literal false must be compared", "selector": "$[?false]", "invalid_selector": true }, { "name": "filter, literal string must be compared", "selector": "$[?'abc']", "invalid_selector": true }, { "name": "filter, literal int must be compared", "selector": "$[?2]", "invalid_selector": true }, { "name": "filter, literal float must be compared", "selector": "$[?2.2]", "invalid_selector": true }, { "name": "filter, literal null must be compared", "selector": "$[?null]", "invalid_selector": true }, { "name": "filter, and, literals must be compared", "selector": "$[?true && false]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, or, literals must be compared", "selector": "$[?true || false]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, and, right hand literal must be compared", "selector": "$[?true == false && false]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, or, right hand literal must be compared", "selector": "$[?true == false || false]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, and, left hand literal must be compared", "selector": "$[?false && true == false]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, or, left hand literal must be compared", "selector": "$[?false || true == false]", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "filter, true, incorrectly capitalized", "selector": "$[?@==True]", "invalid_selector": true, "tags": [ "case" ] }, { "name": "filter, false, incorrectly capitalized", "selector": "$[?@==False]", "invalid_selector": true, "tags": [ "case" ] }, { "name": "filter, null, incorrectly capitalized", "selector": "$[?@==Null]", "invalid_selector": true, "tags": [ "case" ] }, { "name": "index selector, first element", "selector": "$[0]", "document": [ "first", "second" ], "result": [ "first" ], "result_paths": [ "$[0]" ], "tags": [ "index" ] }, { "name": "index selector, second element", "selector": "$[1]", "document": [ "first", "second" ], "result": [ "second" ], "result_paths": [ "$[1]" ], "tags": [ "index" ] }, { "name": "index selector, out of bound", "selector": "$[2]", "document": [ "first", "second" ], "result": [], "result_paths": [], "tags": [ "boundary", "index" ] }, { "name": "index selector, min exact index", "selector": "$[-9007199254740991]", "document": [ "first", "second" ], "result": [], "result_paths": [], "tags": [ "boundary", "index" ] }, { "name": "index selector, max exact index", "selector": "$[9007199254740991]", "document": [ "first", "second" ], "result": [], "result_paths": [], "tags": [ "boundary", "index" ] }, { "name": "index selector, min exact index - 1", "selector": "$[-9007199254740992]", "invalid_selector": true, "tags": [ "boundary", "index" ] }, { "name": "index selector, max exact index + 1", "selector": "$[9007199254740992]", "invalid_selector": true, "tags": [ "boundary", "index" ] }, { "name": "index selector, overflowing index", "selector": "$[231584178474632390847141970017375815706539969331281128078915168015826259279872]", "invalid_selector": true, "tags": [ "boundary", "index" ] }, { "name": "index selector, not actually an index, overflowing index leads into general text", "selector": "$[231584178474632390847141970017375815706539969331281128078915168SomeRandomText]", "invalid_selector": true, "tags": [ "index" ] }, { "name": "index selector, negative", "selector": "$[-1]", "document": [ "first", "second" ], "result": [ "second" ], "result_paths": [ "$[1]" ], "tags": [ "index" ] }, { "name": "index selector, more negative", "selector": "$[-2]", "document": [ "first", "second" ], "result": [ "first" ], "result_paths": [ "$[0]" ], "tags": [ "index" ] }, { "name": "index selector, negative out of bound", "selector": "$[-3]", "document": [ "first", "second" ], "result": [], "result_paths": [], "tags": [ "boundary", "index" ] }, { "name": "index selector, on object", "selector": "$[0]", "document": { "foo": 1 }, "result": [], "result_paths": [], "tags": [ "index" ] }, { "name": "index selector, leading 0", "selector": "$[01]", "invalid_selector": true, "tags": [ "index" ] }, { "name": "index selector, decimal", "selector": "$[1.0]", "invalid_selector": true, "tags": [ "index" ] }, { "name": "index selector, plus", "selector": "$[+1]", "invalid_selector": true, "tags": [ "index" ] }, { "name": "index selector, minus space", "selector": "$[- 1]", "invalid_selector": true, "tags": [ "index", "whitespace" ] }, { "name": "index selector, -0", "selector": "$[-0]", "invalid_selector": true, "tags": [ "index" ] }, { "name": "index selector, leading -0", "selector": "$[-01]", "invalid_selector": true, "tags": [ "index" ] }, { "name": "name selector, double quotes", "selector": "$[\"a\"]", "document": { "a": "A", "b": "B" }, "result": [ "A" ], "result_paths": [ "$['a']" ] }, { "name": "name selector, double quotes, absent data", "selector": "$[\"c\"]", "document": { "a": "A", "b": "B" }, "result": [], "result_paths": [] }, { "name": "name selector, double quotes, array data", "selector": "$[\"a\"]", "document": [ "first", "second" ], "result": [], "result_paths": [] }, { "name": "name selector, name, double quotes, contains single quote", "selector": "$[\"a'\"]", "document": { "a'": "A", "b": "B" }, "result": [ "A" ], "result_paths": [ "$['a\\'']" ] }, { "name": "name selector, name, double quotes, nested", "selector": "$[\"a\"][\"b\"][\"c\"]", "document": { "a": { "b": { "c": "C" } } }, "result": [ "C" ], "result_paths": [ "$['a']['b']['c']" ] }, { "name": "name selector, double quotes, embedded U+0000", "selector": "$[\"\u0000\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0001", "selector": "$[\"\u0001\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0002", "selector": "$[\"\u0002\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0003", "selector": "$[\"\u0003\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0004", "selector": "$[\"\u0004\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0005", "selector": "$[\"\u0005\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0006", "selector": "$[\"\u0006\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0007", "selector": "$[\"\u0007\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0008", "selector": "$[\"\b\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0009", "selector": "$[\"\t\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+000A", "selector": "$[\"\n\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+000B", "selector": "$[\"\u000b\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+000C", "selector": "$[\"\f\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+000D", "selector": "$[\"\r\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+000E", "selector": "$[\"\u000e\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+000F", "selector": "$[\"\u000f\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0010", "selector": "$[\"\u0010\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0011", "selector": "$[\"\u0011\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0012", "selector": "$[\"\u0012\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0013", "selector": "$[\"\u0013\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0014", "selector": "$[\"\u0014\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0015", "selector": "$[\"\u0015\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0016", "selector": "$[\"\u0016\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0017", "selector": "$[\"\u0017\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0018", "selector": "$[\"\u0018\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0019", "selector": "$[\"\u0019\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+001A", "selector": "$[\"\u001a\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+001B", "selector": "$[\"\u001b\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+001C", "selector": "$[\"\u001c\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+001D", "selector": "$[\"\u001d\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+001E", "selector": "$[\"\u001e\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+001F", "selector": "$[\"\u001f\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+0020", "selector": "$[\" \"]", "document": { " ": "A" }, "result": [ "A" ], "result_paths": [ "$[' ']" ], "tags": [ "unicode" ] }, { "name": "name selector, double quotes, embedded U+007F", "selector": "$[\"\"]", "document": { "": "A" }, "result": [ "A" ], "result_paths": [ "$['']" ], "tags": [ "unicode" ] }, { "name": "name selector, double quotes, supplementary plane character", "selector": "$[\"𝄞\"]", "document": { "𝄞": "A" }, "result": [ "A" ], "result_paths": [ "$['𝄞']" ], "tags": [ "unicode" ] }, { "name": "name selector, double quotes, escaped double quote", "selector": "$[\"\\\"\"]", "document": { "\"": "A" }, "result": [ "A" ], "result_paths": [ "$['\"']" ] }, { "name": "name selector, double quotes, escaped reverse solidus", "selector": "$[\"\\\\\"]", "document": { "\\": "A" }, "result": [ "A" ], "result_paths": [ "$['\\\\']" ] }, { "name": "name selector, double quotes, escaped solidus", "selector": "$[\"\\/\"]", "document": { "/": "A" }, "result": [ "A" ], "result_paths": [ "$['/']" ] }, { "name": "name selector, double quotes, escaped backspace", "selector": "$[\"\\b\"]", "document": { "\b": "A" }, "result": [ "A" ], "result_paths": [ "$['\\b']" ] }, { "name": "name selector, double quotes, escaped form feed", "selector": "$[\"\\f\"]", "document": { "\f": "A" }, "result": [ "A" ], "result_paths": [ "$['\\f']" ] }, { "name": "name selector, double quotes, escaped line feed", "selector": "$[\"\\n\"]", "document": { "\n": "A" }, "result": [ "A" ], "result_paths": [ "$['\\n']" ] }, { "name": "name selector, double quotes, escaped carriage return", "selector": "$[\"\\r\"]", "document": { "\r": "A" }, "result": [ "A" ], "result_paths": [ "$['\\r']" ] }, { "name": "name selector, double quotes, escaped tab", "selector": "$[\"\\t\"]", "document": { "\t": "A" }, "result": [ "A" ], "result_paths": [ "$['\\t']" ] }, { "name": "name selector, double quotes, escaped ☺, upper case hex", "selector": "$[\"\\u263A\"]", "document": { "☺": "A" }, "result": [ "A" ], "result_paths": [ "$['☺']" ], "tags": [ "unicode" ] }, { "name": "name selector, double quotes, escaped ☺, lower case hex", "selector": "$[\"\\u263a\"]", "document": { "☺": "A" }, "result": [ "A" ], "result_paths": [ "$['☺']" ], "tags": [ "unicode" ] }, { "name": "name selector, double quotes, surrogate pair 𝄞", "selector": "$[\"\\uD834\\uDD1E\"]", "document": { "𝄞": "A" }, "result": [ "A" ], "result_paths": [ "$['𝄞']" ], "tags": [ "unicode" ] }, { "name": "name selector, double quotes, surrogate pair 😀", "selector": "$[\"\\uD83D\\uDE00\"]", "document": { "😀": "A" }, "result": [ "A" ], "result_paths": [ "$['😀']" ], "tags": [ "unicode" ] }, { "name": "name selector, double quotes, before high surrogates", "selector": "$[\"\\uD7FF\\uD7FF\"]", "document": { "퟿퟿": "A" }, "result": [ "A" ], "result_paths": [ "$['퟿퟿']" ], "tags": [ "unicode" ] }, { "name": "name selector, double quotes, after low surrogates", "selector": "$[\"\\uE000\\uE000\"]", "document": { "": "A" }, "result": [ "A" ], "result_paths": [ "$['']" ], "tags": [ "unicode" ] }, { "name": "name selector, double quotes, invalid escaped single quote", "selector": "$[\"\\'\"]", "invalid_selector": true }, { "name": "name selector, double quotes, embedded double quote", "selector": "$[\"\"\"]", "invalid_selector": true }, { "name": "name selector, double quotes, incomplete escape", "selector": "$[\"\\\"]", "invalid_selector": true }, { "name": "name selector, double quotes, escape at end of line", "selector": "$[\"\\\n\"]", "invalid_selector": true }, { "name": "name selector, double quotes, question mark escape", "selector": "$[\"\\?\"]", "invalid_selector": true }, { "name": "name selector, double quotes, bell escape", "selector": "$[\"\\a\"]", "invalid_selector": true }, { "name": "name selector, double quotes, vertical tab escape", "selector": "$[\"\\v\"]", "invalid_selector": true }, { "name": "name selector, double quotes, 0 escape", "selector": "$[\"\\0\"]", "invalid_selector": true }, { "name": "name selector, double quotes, x escape", "selector": "$[\"\\x12\"]", "invalid_selector": true }, { "name": "name selector, double quotes, n escape", "selector": "$[\"\\N{LATIN CAPITAL LETTER A}\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, unicode escape no hex", "selector": "$[\"\\u\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, unicode escape too few hex", "selector": "$[\"\\u123\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, unicode escape upper u", "selector": "$[\"\\U1234\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, unicode escape upper u long", "selector": "$[\"\\U0010FFFF\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, unicode escape plus", "selector": "$[\"\\u+1234\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, unicode escape brackets", "selector": "$[\"\\u{1234}\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, unicode escape brackets long", "selector": "$[\"\\u{10ffff}\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, single high surrogate", "selector": "$[\"\\uD800\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, single low surrogate", "selector": "$[\"\\uDC00\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, high high surrogate", "selector": "$[\"\\uD800\\uD800\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, low low surrogate", "selector": "$[\"\\uDC00\\uDC00\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, surrogate non-surrogate", "selector": "$[\"\\uD800\\u1234\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, non-surrogate surrogate", "selector": "$[\"\\u1234\\uDC00\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, surrogate supplementary", "selector": "$[\"\\uD800𝄞\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, supplementary surrogate", "selector": "$[\"𝄞\\uDC00\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, double quotes, surrogate incomplete low", "selector": "$[\"\\uD800\\uDC0\"]", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes", "selector": "$['a']", "document": { "a": "A", "b": "B" }, "result": [ "A" ], "result_paths": [ "$['a']" ] }, { "name": "name selector, single quotes, absent data", "selector": "$['c']", "document": { "a": "A", "b": "B" }, "result": [], "result_paths": [] }, { "name": "name selector, single quotes, array data", "selector": "$['a']", "document": [ "first", "second" ], "result": [], "result_paths": [] }, { "name": "name selector, single quotes, embedded U+0000", "selector": "$['\u0000']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0001", "selector": "$['\u0001']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0002", "selector": "$['\u0002']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0003", "selector": "$['\u0003']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0004", "selector": "$['\u0004']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0005", "selector": "$['\u0005']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0006", "selector": "$['\u0006']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0007", "selector": "$['\u0007']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0008", "selector": "$['\b']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0009", "selector": "$['\t']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+000A", "selector": "$['\n']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+000B", "selector": "$['\u000b']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+000C", "selector": "$['\f']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+000D", "selector": "$['\r']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+000E", "selector": "$['\u000e']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+000F", "selector": "$['\u000f']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0010", "selector": "$['\u0010']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0011", "selector": "$['\u0011']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0012", "selector": "$['\u0012']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0013", "selector": "$['\u0013']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0014", "selector": "$['\u0014']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0015", "selector": "$['\u0015']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0016", "selector": "$['\u0016']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0017", "selector": "$['\u0017']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0018", "selector": "$['\u0018']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0019", "selector": "$['\u0019']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+001A", "selector": "$['\u001a']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+001B", "selector": "$['\u001b']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+001C", "selector": "$['\u001c']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+001D", "selector": "$['\u001d']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+001E", "selector": "$['\u001e']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+001F", "selector": "$['\u001f']", "invalid_selector": true, "tags": [ "unicode" ] }, { "name": "name selector, single quotes, embedded U+0020", "selector": "$[' ']", "document": { " ": "A" }, "result": [ "A" ], "result_paths": [ "$[' ']" ], "tags": [ "unicode" ] }, { "name": "name selector, single quotes, escaped single quote", "selector": "$['\\'']", "document": { "'": "A" }, "result": [ "A" ], "result_paths": [ "$['\\'']" ] }, { "name": "name selector, single quotes, escaped reverse solidus", "selector": "$['\\\\']", "document": { "\\": "A" }, "result": [ "A" ], "result_paths": [ "$['\\\\']" ] }, { "name": "name selector, single quotes, escaped solidus", "selector": "$['\\/']", "document": { "/": "A" }, "result": [ "A" ], "result_paths": [ "$['/']" ], "tags": [ "unicode" ] }, { "name": "name selector, single quotes, escaped backspace", "selector": "$['\\b']", "document": { "\b": "A" }, "result": [ "A" ], "result_paths": [ "$['\\b']" ] }, { "name": "name selector, single quotes, escaped form feed", "selector": "$['\\f']", "document": { "\f": "A" }, "result": [ "A" ], "result_paths": [ "$['\\f']" ] }, { "name": "name selector, single quotes, escaped line feed", "selector": "$['\\n']", "document": { "\n": "A" }, "result": [ "A" ], "result_paths": [ "$['\\n']" ] }, { "name": "name selector, single quotes, escaped carriage return", "selector": "$['\\r']", "document": { "\r": "A" }, "result": [ "A" ], "result_paths": [ "$['\\r']" ] }, { "name": "name selector, single quotes, escaped tab", "selector": "$['\\t']", "document": { "\t": "A" }, "result": [ "A" ], "result_paths": [ "$['\\t']" ] }, { "name": "name selector, single quotes, escaped ☺, upper case hex", "selector": "$['\\u263A']", "document": { "☺": "A" }, "result": [ "A" ], "result_paths": [ "$['☺']" ], "tags": [ "unicode" ] }, { "name": "name selector, single quotes, escaped ☺, lower case hex", "selector": "$['\\u263a']", "document": { "☺": "A" }, "result": [ "A" ], "result_paths": [ "$['☺']" ], "tags": [ "unicode" ] }, { "name": "name selector, single quotes, surrogate pair 𝄞", "selector": "$['\\uD834\\uDD1E']", "document": { "𝄞": "A" }, "result": [ "A" ], "result_paths": [ "$['𝄞']" ], "tags": [ "unicode" ] }, { "name": "name selector, single quotes, surrogate pair 😀", "selector": "$['\\uD83D\\uDE00']", "document": { "😀": "A" }, "result": [ "A" ], "result_paths": [ "$['😀']" ], "tags": [ "unicode" ] }, { "name": "name selector, single quotes, invalid escaped double quote", "selector": "$['\\\"']", "invalid_selector": true }, { "name": "name selector, single quotes, embedded single quote", "selector": "$[''']", "invalid_selector": true }, { "name": "name selector, single quotes, incomplete escape", "selector": "$['\\']", "invalid_selector": true }, { "name": "name selector, double quotes, empty", "selector": "$[\"\"]", "document": { "a": "A", "b": "B", "": "C" }, "result": [ "C" ], "result_paths": [ "$['']" ] }, { "name": "name selector, single quotes, empty", "selector": "$['']", "document": { "a": "A", "b": "B", "": "C" }, "result": [ "C" ], "result_paths": [ "$['']" ] }, { "name": "slice selector, slice selector", "selector": "$[1:3]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 1, 2 ], "result_paths": [ "$[1]", "$[2]" ], "tags": [ "slice" ] }, { "name": "slice selector, slice selector with step", "selector": "$[1:6:2]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 1, 3, 5 ], "result_paths": [ "$[1]", "$[3]", "$[5]" ], "tags": [ "slice" ] }, { "name": "slice selector, slice selector with everything omitted, short form", "selector": "$[:]", "document": [ 0, 1, 2, 3 ], "result": [ 0, 1, 2, 3 ], "result_paths": [ "$[0]", "$[1]", "$[2]", "$[3]" ], "tags": [ "slice" ] }, { "name": "slice selector, slice selector with everything omitted, long form", "selector": "$[::]", "document": [ 0, 1, 2, 3 ], "result": [ 0, 1, 2, 3 ], "result_paths": [ "$[0]", "$[1]", "$[2]", "$[3]" ], "tags": [ "slice" ] }, { "name": "slice selector, slice selector with start omitted", "selector": "$[:2]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 0, 1 ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "slice" ] }, { "name": "slice selector, slice selector with start and end omitted", "selector": "$[::2]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 0, 2, 4, 6, 8 ], "result_paths": [ "$[0]", "$[2]", "$[4]", "$[6]", "$[8]" ], "tags": [ "slice" ] }, { "name": "slice selector, negative step with default start and end", "selector": "$[::-1]", "document": [ 0, 1, 2, 3 ], "result": [ 3, 2, 1, 0 ], "result_paths": [ "$[3]", "$[2]", "$[1]", "$[0]" ], "tags": [ "slice" ] }, { "name": "slice selector, negative step with default start", "selector": "$[:0:-1]", "document": [ 0, 1, 2, 3 ], "result": [ 3, 2, 1 ], "result_paths": [ "$[3]", "$[2]", "$[1]" ], "tags": [ "slice" ] }, { "name": "slice selector, negative step with default end", "selector": "$[2::-1]", "document": [ 0, 1, 2, 3 ], "result": [ 2, 1, 0 ], "result_paths": [ "$[2]", "$[1]", "$[0]" ], "tags": [ "slice" ] }, { "name": "slice selector, larger negative step", "selector": "$[::-2]", "document": [ 0, 1, 2, 3 ], "result": [ 3, 1 ], "result_paths": [ "$[3]", "$[1]" ], "tags": [ "slice" ] }, { "name": "slice selector, negative range with default step", "selector": "$[-1:-3]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [], "result_paths": [], "tags": [ "slice" ] }, { "name": "slice selector, negative range with negative step", "selector": "$[-1:-3:-1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 9, 8 ], "result_paths": [ "$[9]", "$[8]" ], "tags": [ "slice" ] }, { "name": "slice selector, negative range with larger negative step", "selector": "$[-1:-6:-2]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 9, 7, 5 ], "result_paths": [ "$[9]", "$[7]", "$[5]" ], "tags": [ "slice" ] }, { "name": "slice selector, larger negative range with larger negative step", "selector": "$[-1:-7:-2]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 9, 7, 5 ], "result_paths": [ "$[9]", "$[7]", "$[5]" ], "tags": [ "slice" ] }, { "name": "slice selector, negative from, positive to", "selector": "$[-5:7]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 5, 6 ], "result_paths": [ "$[5]", "$[6]" ], "tags": [ "slice" ] }, { "name": "slice selector, negative from", "selector": "$[-2:]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 8, 9 ], "result_paths": [ "$[8]", "$[9]" ], "tags": [ "slice" ] }, { "name": "slice selector, positive from, negative to", "selector": "$[1:-1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 1, 2, 3, 4, 5, 6, 7, 8 ], "result_paths": [ "$[1]", "$[2]", "$[3]", "$[4]", "$[5]", "$[6]", "$[7]", "$[8]" ], "tags": [ "slice" ] }, { "name": "slice selector, negative from, positive to, negative step", "selector": "$[-1:1:-1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 9, 8, 7, 6, 5, 4, 3, 2 ], "result_paths": [ "$[9]", "$[8]", "$[7]", "$[6]", "$[5]", "$[4]", "$[3]", "$[2]" ], "tags": [ "slice" ] }, { "name": "slice selector, positive from, negative to, negative step", "selector": "$[7:-5:-1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 7, 6 ], "result_paths": [ "$[7]", "$[6]" ], "tags": [ "slice" ] }, { "name": "slice selector, in serial, on nested array", "selector": "$[1:3][1:2]", "document": [ [ "a", "b", "c" ], [ "d", "e", "f" ], [ "g", "h", "i" ] ], "result": [ "e", "h" ], "result_paths": [ "$[1][1]", "$[2][1]" ], "tags": [ "slice" ] }, { "name": "slice selector, in serial, on flat array", "selector": "$[1:3][::]", "document": [ 0, 1, 2, 3, 4, 5 ], "result": [], "result_paths": [], "tags": [ "slice" ] }, { "name": "slice selector, negative from, negative to, positive step", "selector": "$[-5:-2]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 5, 6, 7 ], "result_paths": [ "$[5]", "$[6]", "$[7]" ], "tags": [ "slice" ] }, { "name": "slice selector, too many colons", "selector": "$[1:2:3:4]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, non-integer array index", "selector": "$[1:2:a]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, zero step", "selector": "$[1:2:0]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [], "result_paths": [], "tags": [ "slice" ] }, { "name": "slice selector, empty range", "selector": "$[2:2]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [], "result_paths": [], "tags": [ "slice" ] }, { "name": "slice selector, slice selector with everything omitted with empty array", "selector": "$[:]", "document": [], "result": [], "result_paths": [], "tags": [ "slice" ] }, { "name": "slice selector, negative step with empty array", "selector": "$[::-1]", "document": [], "result": [], "result_paths": [], "tags": [ "slice" ] }, { "name": "slice selector, maximal range with positive step", "selector": "$[0:10]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result_paths": [ "$[0]", "$[1]", "$[2]", "$[3]", "$[4]", "$[5]", "$[6]", "$[7]", "$[8]", "$[9]" ], "tags": [ "slice" ] }, { "name": "slice selector, maximal range with negative step", "selector": "$[9:0:-1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 9, 8, 7, 6, 5, 4, 3, 2, 1 ], "result_paths": [ "$[9]", "$[8]", "$[7]", "$[6]", "$[5]", "$[4]", "$[3]", "$[2]", "$[1]" ], "tags": [ "slice" ] }, { "name": "slice selector, excessively large to value", "selector": "$[2:113667776004]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 2, 3, 4, 5, 6, 7, 8, 9 ], "result_paths": [ "$[2]", "$[3]", "$[4]", "$[5]", "$[6]", "$[7]", "$[8]", "$[9]" ], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, excessively small from value", "selector": "$[-113667776004:1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 0 ], "result_paths": [ "$[0]" ], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, excessively large from value with negative step", "selector": "$[113667776004:0:-1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 9, 8, 7, 6, 5, 4, 3, 2, 1 ], "result_paths": [ "$[9]", "$[8]", "$[7]", "$[6]", "$[5]", "$[4]", "$[3]", "$[2]", "$[1]" ], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, excessively small to value with negative step", "selector": "$[3:-113667776004:-1]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 3, 2, 1, 0 ], "result_paths": [ "$[3]", "$[2]", "$[1]", "$[0]" ], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, excessively large step", "selector": "$[1:10:113667776004]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 1 ], "result_paths": [ "$[1]" ], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, excessively small step", "selector": "$[-1:-10:-113667776004]", "document": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "result": [ 9 ], "result_paths": [ "$[9]" ], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, start, min exact", "selector": "$[-9007199254740991::]", "document": [], "result": [], "result_paths": [], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, start, max exact", "selector": "$[9007199254740991::]", "document": [], "result": [], "result_paths": [], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, start, min exact - 1", "selector": "$[-9007199254740992::]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, start, max exact + 1", "selector": "$[9007199254740992::]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, end, min exact", "selector": "$[:-9007199254740991:]", "document": [], "result": [], "result_paths": [], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, end, max exact", "selector": "$[:9007199254740991:]", "document": [], "result": [], "result_paths": [], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, end, min exact - 1", "selector": "$[:-9007199254740992:]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, end, max exact + 1", "selector": "$[:9007199254740992:]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, step, min exact", "selector": "$[::-9007199254740991]", "document": [], "result": [], "result_paths": [], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, step, max exact", "selector": "$[::9007199254740991]", "document": [], "result": [], "result_paths": [], "tags": [ "boundary", "slice" ] }, { "name": "slice selector, step, min exact - 1", "selector": "$[::-9007199254740992]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, step, max exact + 1", "selector": "$[::9007199254740992]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, overflowing to value", "selector": "$[2:231584178474632390847141970017375815706539969331281128078915168015826259279872]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, underflowing from value", "selector": "$[-231584178474632390847141970017375815706539969331281128078915168015826259279872:1]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, overflowing from value with negative step", "selector": "$[231584178474632390847141970017375815706539969331281128078915168015826259279872:0:-1]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, underflowing to value with negative step", "selector": "$[3:-231584178474632390847141970017375815706539969331281128078915168015826259279872:-1]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, overflowing step", "selector": "$[1:10:231584178474632390847141970017375815706539969331281128078915168015826259279872]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, underflowing step", "selector": "$[-1:-10:-231584178474632390847141970017375815706539969331281128078915168015826259279872]", "invalid_selector": true, "tags": [ "boundary", "slice" ] }, { "name": "slice selector, start, leading 0", "selector": "$[01::]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, start, decimal", "selector": "$[1.0::]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, start, plus", "selector": "$[+1::]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, start, minus space", "selector": "$[- 1::]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, start, -0", "selector": "$[-0::]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, start, leading -0", "selector": "$[-01::]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, end, leading 0", "selector": "$[:01:]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, end, decimal", "selector": "$[:1.0:]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, end, plus", "selector": "$[:+1:]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, end, minus space", "selector": "$[:- 1:]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, end, -0", "selector": "$[:-0:]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, end, leading -0", "selector": "$[:-01:]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, step, leading 0", "selector": "$[::01]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, step, decimal", "selector": "$[::1.0]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, step, plus", "selector": "$[::+1]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, step, minus space", "selector": "$[::- 1]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, step, -0", "selector": "$[::-0]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "slice selector, step, leading -0", "selector": "$[::-01]", "invalid_selector": true, "tags": [ "slice" ] }, { "name": "functions, count, count function", "selector": "$[?count(@..*)>2]", "document": [ { "a": [ 1, 2, 3 ] }, { "a": [ 1 ], "d": "f" }, { "a": 1, "d": "f" } ], "result": [ { "a": [ 1, 2, 3 ] }, { "a": [ 1 ], "d": "f" } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "count", "function" ] }, { "name": "functions, count, single-node arg", "selector": "$[?count(@.a)>1]", "document": [ { "a": [ 1, 2, 3 ] }, { "a": [ 1 ], "d": "f" }, { "a": 1, "d": "f" } ], "result": [], "result_paths": [], "tags": [ "count", "function" ] }, { "name": "functions, count, multiple-selector arg", "selector": "$[?count(@['a','d'])>1]", "document": [ { "a": [ 1, 2, 3 ] }, { "a": [ 1 ], "d": "f" }, { "a": 1, "d": "f" } ], "result": [ { "a": [ 1 ], "d": "f" }, { "a": 1, "d": "f" } ], "result_paths": [ "$[1]", "$[2]" ], "tags": [ "count", "function" ] }, { "name": "functions, count, non-query arg, number", "selector": "$[?count(1)>2]", "invalid_selector": true, "tags": [ "count", "function" ] }, { "name": "functions, count, non-query arg, string", "selector": "$[?count('string')>2]", "invalid_selector": true, "tags": [ "count", "function" ] }, { "name": "functions, count, non-query arg, true", "selector": "$[?count(true)>2]", "invalid_selector": true, "tags": [ "count", "function" ] }, { "name": "functions, count, non-query arg, false", "selector": "$[?count(false)>2]", "invalid_selector": true, "tags": [ "count", "function" ] }, { "name": "functions, count, non-query arg, null", "selector": "$[?count(null)>2]", "invalid_selector": true, "tags": [ "count", "function" ] }, { "name": "functions, count, result must be compared", "selector": "$[?count(@..*)]", "invalid_selector": true, "tags": [ "count", "function" ] }, { "name": "functions, count, no params", "selector": "$[?count()==1]", "invalid_selector": true, "tags": [ "count", "function" ] }, { "name": "functions, count, too many params", "selector": "$[?count(@.a,@.b)==1]", "invalid_selector": true, "tags": [ "count", "function" ] }, { "name": "functions, length, string data", "selector": "$[?length(@.a)>=2]", "document": [ { "a": "ab" }, { "a": "d" } ], "result": [ { "a": "ab" } ], "result_paths": [ "$[0]" ], "tags": [ "function", "length" ] }, { "name": "functions, length, string data, unicode", "selector": "$[?length(@)==2]", "document": [ "☺", "☺☺", "☺☺☺", "ж", "жж", "жжж", "磨", "阿美", "形声字" ], "result": [ "☺☺", "жж", "阿美" ], "result_paths": [ "$[1]", "$[4]", "$[7]" ], "tags": [ "function", "length" ] }, { "name": "functions, length, array data", "selector": "$[?length(@.a)>=2]", "document": [ { "a": [ 1, 2, 3 ] }, { "a": [ 1 ] } ], "result": [ { "a": [ 1, 2, 3 ] } ], "result_paths": [ "$[0]" ], "tags": [ "function", "length" ] }, { "name": "functions, length, missing data", "selector": "$[?length(@.a)>=2]", "document": [ { "d": "f" } ], "result": [], "result_paths": [], "tags": [ "function", "length" ] }, { "name": "functions, length, number arg", "selector": "$[?length(1)>=2]", "document": [ { "d": "f" } ], "result": [], "result_paths": [], "tags": [ "function", "length" ] }, { "name": "functions, length, true arg", "selector": "$[?length(true)>=2]", "document": [ { "d": "f" } ], "result": [], "result_paths": [], "tags": [ "function", "length" ] }, { "name": "functions, length, false arg", "selector": "$[?length(false)>=2]", "document": [ { "d": "f" } ], "result": [], "result_paths": [], "tags": [ "function", "length" ] }, { "name": "functions, length, null arg", "selector": "$[?length(null)>=2]", "document": [ { "d": "f" } ], "result": [], "result_paths": [], "tags": [ "function", "length" ] }, { "name": "functions, length, result must be compared", "selector": "$[?length(@.a)]", "invalid_selector": true, "tags": [ "function", "length" ] }, { "name": "functions, length, no params", "selector": "$[?length()==1]", "invalid_selector": true, "tags": [ "function", "length" ] }, { "name": "functions, length, too many params", "selector": "$[?length(@.a,@.b)==1]", "invalid_selector": true, "tags": [ "function", "length" ] }, { "name": "functions, length, non-singular query arg", "selector": "$[?length(@.*)<3]", "invalid_selector": true, "tags": [ "function", "length" ] }, { "name": "functions, length, arg is a function expression", "selector": "$.values[?length(@.a)==length(value($..c))]", "document": { "c": "cd", "values": [ { "a": "ab" }, { "a": "d" } ] }, "result": [ { "a": "ab" } ], "result_paths": [ "$['values'][0]" ], "tags": [ "function", "length" ] }, { "name": "functions, length, arg is special nothing", "selector": "$[?length(value(@.a))>0]", "document": [ { "a": "ab" }, { "c": "d" }, { "a": null } ], "result": [ { "a": "ab" } ], "result_paths": [ "$[0]" ], "tags": [ "function", "length" ] }, { "name": "functions, match, found match", "selector": "$[?match(@.a, 'a.*')]", "document": [ { "a": "ab" } ], "result": [ { "a": "ab" } ], "result_paths": [ "$[0]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, double quotes", "selector": "$[?match(@.a, \"a.*\")]", "document": [ { "a": "ab" } ], "result": [ { "a": "ab" } ], "result_paths": [ "$[0]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, regex from the document", "selector": "$.values[?match(@, $.regex)]", "document": { "regex": "b.?b", "values": [ "abc", "bcd", "bab", "bba", "bbab", "b", true, [], {} ] }, "result": [ "bab" ], "result_paths": [ "$['values'][2]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, don't select match", "selector": "$[?!match(@.a, 'a.*')]", "document": [ { "a": "ab" } ], "result": [], "result_paths": [], "tags": [ "function", "match" ] }, { "name": "functions, match, not a match", "selector": "$[?match(@.a, 'a.*')]", "document": [ { "a": "bc" } ], "result": [], "result_paths": [], "tags": [ "function", "match" ] }, { "name": "functions, match, select non-match", "selector": "$[?!match(@.a, 'a.*')]", "document": [ { "a": "bc" } ], "result": [ { "a": "bc" } ], "result_paths": [ "$[0]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, non-string first arg", "selector": "$[?match(1, 'a.*')]", "document": [ { "a": "bc" } ], "result": [], "result_paths": [], "tags": [ "function", "match" ] }, { "name": "functions, match, non-string second arg", "selector": "$[?match(@.a, 1)]", "document": [ { "a": "bc" } ], "result": [], "result_paths": [], "tags": [ "function", "match" ] }, { "name": "functions, match, filter, match function, unicode char class, uppercase", "selector": "$[?match(@, '\\\\p{Lu}')]", "document": [ "ж", "Ж", "1", "жЖ", true, [], {} ], "result": [ "Ж" ], "result_paths": [ "$[1]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, filter, match function, unicode char class negated, uppercase", "selector": "$[?match(@, '\\\\P{Lu}')]", "document": [ "ж", "Ж", "1", true, [], {} ], "result": [ "ж", "1" ], "result_paths": [ "$[0]", "$[2]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, filter, match function, unicode, surrogate pair", "selector": "$[?match(@, 'a.b')]", "document": [ "a𐄁b", "ab", "1", true, [], {} ], "result": [ "a𐄁b" ], "result_paths": [ "$[0]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, dot matcher on \\u2028", "selector": "$[?match(@, '.')]", "document": [ "", "\r", "\n", true, [], {} ], "result": [ "" ], "result_paths": [ "$[0]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, dot matcher on \\u2029", "selector": "$[?match(@, '.')]", "document": [ "", "\r", "\n", true, [], {} ], "result": [ "" ], "result_paths": [ "$[0]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, result cannot be compared", "selector": "$[?match(@.a, 'a.*')==true]", "invalid_selector": true, "tags": [ "function", "match" ] }, { "name": "functions, match, too few params", "selector": "$[?match(@.a)==1]", "invalid_selector": true, "tags": [ "function", "match" ] }, { "name": "functions, match, too many params", "selector": "$[?match(@.a,@.b,@.c)==1]", "invalid_selector": true, "tags": [ "function", "match" ] }, { "name": "functions, match, arg is a function expression", "selector": "$.values[?match(@.a, value($..['regex']))]", "document": { "regex": "a.*", "values": [ { "a": "ab" }, { "a": "ba" } ] }, "result": [ { "a": "ab" } ], "result_paths": [ "$['values'][0]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, dot in character class", "selector": "$[?match(@, 'a[.b]c')]", "document": [ "abc", "a.c", "axc" ], "result": [ "abc", "a.c" ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, escaped dot", "selector": "$[?match(@, 'a\\\\.c')]", "document": [ "abc", "a.c", "axc" ], "result": [ "a.c" ], "result_paths": [ "$[1]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, escaped backslash before dot", "selector": "$[?match(@, 'a\\\\\\\\.c')]", "document": [ "abc", "a.c", "axc", "a\\c" ], "result": [ "a\\c" ], "result_paths": [ "$[3]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, escaped left square bracket", "selector": "$[?match(@, 'a\\\\[.c')]", "document": [ "abc", "a.c", "a[c" ], "result": [ "a[c" ], "result_paths": [ "$[2]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, escaped right square bracket", "selector": "$[?match(@, 'a[\\\\].]c')]", "document": [ "abc", "a.c", "ac", "a]c" ], "result": [ "a.c", "a]c" ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, explicit caret", "selector": "$[?match(@, '^ab.*')]", "document": [ "abc", "axc", "ab", "xab" ], "result": [ "abc", "ab" ], "result_paths": [ "$[0]", "$[2]" ], "tags": [ "function", "match" ] }, { "name": "functions, match, explicit dollar", "selector": "$[?match(@, '.*bc$')]", "document": [ "abc", "axc", "ab", "abcx" ], "result": [ "abc" ], "result_paths": [ "$[0]" ], "tags": [ "function", "match" ] }, { "name": "functions, search, at the end", "selector": "$[?search(@.a, 'a.*')]", "document": [ { "a": "the end is ab" } ], "result": [ { "a": "the end is ab" } ], "result_paths": [ "$[0]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, double quotes", "selector": "$[?search(@.a, \"a.*\")]", "document": [ { "a": "the end is ab" } ], "result": [ { "a": "the end is ab" } ], "result_paths": [ "$[0]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, at the start", "selector": "$[?search(@.a, 'a.*')]", "document": [ { "a": "ab is at the start" } ], "result": [ { "a": "ab is at the start" } ], "result_paths": [ "$[0]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, in the middle", "selector": "$[?search(@.a, 'a.*')]", "document": [ { "a": "contains two matches" } ], "result": [ { "a": "contains two matches" } ], "result_paths": [ "$[0]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, regex from the document", "selector": "$.values[?search(@, $.regex)]", "document": { "regex": "b.?b", "values": [ "abc", "bcd", "bab", "bba", "bbab", "b", true, [], {} ] }, "result": [ "bab", "bba", "bbab" ], "result_paths": [ "$['values'][2]", "$['values'][3]", "$['values'][4]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, don't select match", "selector": "$[?!search(@.a, 'a.*')]", "document": [ { "a": "contains two matches" } ], "result": [], "result_paths": [], "tags": [ "function", "search" ] }, { "name": "functions, search, not a match", "selector": "$[?search(@.a, 'a.*')]", "document": [ { "a": "bc" } ], "result": [], "result_paths": [], "tags": [ "function", "search" ] }, { "name": "functions, search, select non-match", "selector": "$[?!search(@.a, 'a.*')]", "document": [ { "a": "bc" } ], "result": [ { "a": "bc" } ], "result_paths": [ "$[0]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, non-string first arg", "selector": "$[?search(1, 'a.*')]", "document": [ { "a": "bc" } ], "result": [], "result_paths": [], "tags": [ "function", "search" ] }, { "name": "functions, search, non-string second arg", "selector": "$[?search(@.a, 1)]", "document": [ { "a": "bc" } ], "result": [], "result_paths": [], "tags": [ "function", "search" ] }, { "name": "functions, search, filter, search function, unicode char class, uppercase", "selector": "$[?search(@, '\\\\p{Lu}')]", "document": [ "ж", "Ж", "1", "жЖ", true, [], {} ], "result": [ "Ж", "жЖ" ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, filter, search function, unicode char class negated, uppercase", "selector": "$[?search(@, '\\\\P{Lu}')]", "document": [ "ж", "Ж", "1", true, [], {} ], "result": [ "ж", "1" ], "result_paths": [ "$[0]", "$[2]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, filter, search function, unicode, surrogate pair", "selector": "$[?search(@, 'a.b')]", "document": [ "a𐄁bc", "abc", "1", true, [], {} ], "result": [ "a𐄁bc" ], "result_paths": [ "$[0]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, dot matcher on \\u2028", "selector": "$[?search(@, '.')]", "document": [ "", "\r\n", "\r", "\n", true, [], {} ], "result": [ "", "\r\n" ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, dot matcher on \\u2029", "selector": "$[?search(@, '.')]", "document": [ "", "\r\n", "\r", "\n", true, [], {} ], "result": [ "", "\r\n" ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, result cannot be compared", "selector": "$[?search(@.a, 'a.*')==true]", "invalid_selector": true, "tags": [ "function", "search" ] }, { "name": "functions, search, too few params", "selector": "$[?search(@.a)]", "invalid_selector": true, "tags": [ "function", "search" ] }, { "name": "functions, search, too many params", "selector": "$[?search(@.a,@.b,@.c)]", "invalid_selector": true, "tags": [ "function", "search" ] }, { "name": "functions, search, arg is a function expression", "selector": "$.values[?search(@, value($..['regex']))]", "document": { "regex": "b.?b", "values": [ "abc", "bcd", "bab", "bba", "bbab", "b", true, [], {} ] }, "result": [ "bab", "bba", "bbab" ], "result_paths": [ "$['values'][2]", "$['values'][3]", "$['values'][4]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, dot in character class", "selector": "$[?search(@, 'a[.b]c')]", "document": [ "x abc y", "x a.c y", "x axc y" ], "result": [ "x abc y", "x a.c y" ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, escaped dot", "selector": "$[?search(@, 'a\\\\.c')]", "document": [ "x abc y", "x a.c y", "x axc y" ], "result": [ "x a.c y" ], "result_paths": [ "$[1]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, escaped backslash before dot", "selector": "$[?search(@, 'a\\\\\\\\.c')]", "document": [ "x abc y", "x a.c y", "x axc y", "x a\\c y" ], "result": [ "x a\\c y" ], "result_paths": [ "$[3]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, escaped left square bracket", "selector": "$[?search(@, 'a\\\\[.c')]", "document": [ "x abc y", "x a.c y", "x a[c y" ], "result": [ "x a[c y" ], "result_paths": [ "$[2]" ], "tags": [ "function", "search" ] }, { "name": "functions, search, escaped right square bracket", "selector": "$[?search(@, 'a[\\\\].]c')]", "document": [ "x abc y", "x a.c y", "x ac y", "x a]c y" ], "result": [ "x a.c y", "x a]c y" ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "function", "search" ] }, { "name": "functions, value, single-value nodelist", "selector": "$[?value(@.*)==4]", "document": [ [ 4 ], { "foo": 4 }, [ 5 ], { "foo": 5 }, 4 ], "result": [ [ 4 ], { "foo": 4 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "function", "value" ] }, { "name": "functions, value, multi-value nodelist", "selector": "$[?value(@.*)==4]", "document": [ [ 4, 4 ], { "foo": 4, "bar": 4 } ], "result": [], "result_paths": [], "tags": [ "function", "value" ] }, { "name": "functions, value, too few params", "selector": "$[?value()==4]", "invalid_selector": true, "tags": [ "function", "value" ] }, { "name": "functions, value, too many params", "selector": "$[?value(@.a,@.b)==4]", "invalid_selector": true, "tags": [ "function", "value" ] }, { "name": "functions, value, result must be compared", "selector": "$[?value(@.a)]", "invalid_selector": true, "tags": [ "function", "value" ] }, { "name": "whitespace, filter, space between question mark and expression", "selector": "$[? @.a]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, newline between question mark and expression", "selector": "$[?\n@.a]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, tab between question mark and expression", "selector": "$[?\t@.a]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, return between question mark and expression", "selector": "$[?\r@.a]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, space between question mark and parenthesized expression", "selector": "$[? (@.a)]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, newline between question mark and parenthesized expression", "selector": "$[?\n(@.a)]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, tab between question mark and parenthesized expression", "selector": "$[?\t(@.a)]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, return between question mark and parenthesized expression", "selector": "$[?\r(@.a)]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, space between parenthesized expression and bracket", "selector": "$[?(@.a) ]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, newline between parenthesized expression and bracket", "selector": "$[?(@.a)\n]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, tab between parenthesized expression and bracket", "selector": "$[?(@.a)\t]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, return between parenthesized expression and bracket", "selector": "$[?(@.a)\r]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, space between bracket and question mark", "selector": "$[ ?@.a]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, newline between bracket and question mark", "selector": "$[\n?@.a]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, tab between bracket and question mark", "selector": "$[\t?@.a]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, filter, return between bracket and question mark", "selector": "$[\r?@.a]", "document": [ { "a": "b", "d": "e" }, { "b": "c", "d": "f" } ], "result": [ { "a": "b", "d": "e" } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, functions, space between function name and parenthesis", "selector": "$[?count (@.*)==1]", "invalid_selector": true, "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, newline between function name and parenthesis", "selector": "$[?count\n(@.*)==1]", "invalid_selector": true, "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, tab between function name and parenthesis", "selector": "$[?count\t(@.*)==1]", "invalid_selector": true, "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, return between function name and parenthesis", "selector": "$[?count\r(@.*)==1]", "invalid_selector": true, "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, space between parenthesis and arg", "selector": "$[?count( @.*)==1]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, newline between parenthesis and arg", "selector": "$[?count(\n@.*)==1]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, tab between parenthesis and arg", "selector": "$[?count(\t@.*)==1]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, return between parenthesis and arg", "selector": "$[?count(\r@.*)==1]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, space between arg and comma", "selector": "$[?search(@ ,'[a-z]+')]", "document": [ "foo", "123" ], "result": [ "foo" ], "result_paths": [ "$[0]" ], "tags": [ "function", "search", "whitespace" ] }, { "name": "whitespace, functions, newline between arg and comma", "selector": "$[?search(@\n,'[a-z]+')]", "document": [ "foo", "123" ], "result": [ "foo" ], "result_paths": [ "$[0]" ], "tags": [ "function", "search", "whitespace" ] }, { "name": "whitespace, functions, tab between arg and comma", "selector": "$[?search(@\t,'[a-z]+')]", "document": [ "foo", "123" ], "result": [ "foo" ], "result_paths": [ "$[0]" ], "tags": [ "function", "search", "whitespace" ] }, { "name": "whitespace, functions, return between arg and comma", "selector": "$[?search(@\r,'[a-z]+')]", "document": [ "foo", "123" ], "result": [ "foo" ], "result_paths": [ "$[0]" ], "tags": [ "function", "search", "whitespace" ] }, { "name": "whitespace, functions, space between comma and arg", "selector": "$[?search(@, '[a-z]+')]", "document": [ "foo", "123" ], "result": [ "foo" ], "result_paths": [ "$[0]" ], "tags": [ "function", "search", "whitespace" ] }, { "name": "whitespace, functions, newline between comma and arg", "selector": "$[?search(@,\n'[a-z]+')]", "document": [ "foo", "123" ], "result": [ "foo" ], "result_paths": [ "$[0]" ], "tags": [ "function", "search", "whitespace" ] }, { "name": "whitespace, functions, tab between comma and arg", "selector": "$[?search(@,\t'[a-z]+')]", "document": [ "foo", "123" ], "result": [ "foo" ], "result_paths": [ "$[0]" ], "tags": [ "function", "search", "whitespace" ] }, { "name": "whitespace, functions, return between comma and arg", "selector": "$[?search(@,\r'[a-z]+')]", "document": [ "foo", "123" ], "result": [ "foo" ], "result_paths": [ "$[0]" ], "tags": [ "function", "search", "whitespace" ] }, { "name": "whitespace, functions, space between arg and parenthesis", "selector": "$[?count(@.* )==1]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "function", "search", "whitespace" ] }, { "name": "whitespace, functions, newline between arg and parenthesis", "selector": "$[?count(@.*\n)==1]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, tab between arg and parenthesis", "selector": "$[?count(@.*\t)==1]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, return between arg and parenthesis", "selector": "$[?count(@.*\r)==1]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "count", "function", "whitespace" ] }, { "name": "whitespace, functions, spaces in a relative singular selector", "selector": "$[?length(@ .a .b) == 3]", "document": [ { "a": { "b": "foo" } }, {} ], "result": [ { "a": { "b": "foo" } } ], "result_paths": [ "$[0]" ], "tags": [ "function", "length", "whitespace" ] }, { "name": "whitespace, functions, newlines in a relative singular selector", "selector": "$[?length(@\n.a\n.b) == 3]", "document": [ { "a": { "b": "foo" } }, {} ], "result": [ { "a": { "b": "foo" } } ], "result_paths": [ "$[0]" ], "tags": [ "function", "length", "whitespace" ] }, { "name": "whitespace, functions, tabs in a relative singular selector", "selector": "$[?length(@\t.a\t.b) == 3]", "document": [ { "a": { "b": "foo" } }, {} ], "result": [ { "a": { "b": "foo" } } ], "result_paths": [ "$[0]" ], "tags": [ "function", "length", "whitespace" ] }, { "name": "whitespace, functions, returns in a relative singular selector", "selector": "$[?length(@\r.a\r.b) == 3]", "document": [ { "a": { "b": "foo" } }, {} ], "result": [ { "a": { "b": "foo" } } ], "result_paths": [ "$[0]" ], "tags": [ "function", "length", "whitespace" ] }, { "name": "whitespace, functions, spaces in an absolute singular selector", "selector": "$..[?length(@)==length($ [0] .a)]", "document": [ { "a": "foo" }, {} ], "result": [ "foo" ], "result_paths": [ "$[0]['a']" ], "tags": [ "function", "length", "whitespace" ] }, { "name": "whitespace, functions, newlines in an absolute singular selector", "selector": "$..[?length(@)==length($\n[0]\n.a)]", "document": [ { "a": "foo" }, {} ], "result": [ "foo" ], "result_paths": [ "$[0]['a']" ], "tags": [ "function", "length", "whitespace" ] }, { "name": "whitespace, functions, tabs in an absolute singular selector", "selector": "$..[?length(@)==length($\t[0]\t.a)]", "document": [ { "a": "foo" }, {} ], "result": [ "foo" ], "result_paths": [ "$[0]['a']" ], "tags": [ "function", "length", "whitespace" ] }, { "name": "whitespace, functions, returns in an absolute singular selector", "selector": "$..[?length(@)==length($\r[0]\r.a)]", "document": [ { "a": "foo" }, {} ], "result": [ "foo" ], "result_paths": [ "$[0]['a']" ], "tags": [ "function", "length", "whitespace" ] }, { "name": "whitespace, operators, space before ||", "selector": "$[?@.a ||@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "c": 3 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline before ||", "selector": "$[?@.a\n||@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "c": 3 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab before ||", "selector": "$[?@.a\t||@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "c": 3 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return before ||", "selector": "$[?@.a\r||@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "c": 3 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space after ||", "selector": "$[?@.a|| @.b]", "document": [ { "a": 1 }, { "b": 2 }, { "c": 3 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline after ||", "selector": "$[?@.a||\n@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "c": 3 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab after ||", "selector": "$[?@.a||\t@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "c": 3 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return after ||", "selector": "$[?@.a||\r@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "c": 3 } ], "result": [ { "a": 1 }, { "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space before &&", "selector": "$[?@.a &&@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline before &&", "selector": "$[?@.a\n&&@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab before &&", "selector": "$[?@.a\t&&@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return before &&", "selector": "$[?@.a\r&&@.b]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space after &&", "selector": "$[?@.a&& @.b]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline after &&", "selector": "$[?@.a&& @.b]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab after &&", "selector": "$[?@.a&& @.b]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return after &&", "selector": "$[?@.a&& @.b]", "document": [ { "a": 1 }, { "b": 2 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space before ==", "selector": "$[?@.a ==@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 1 } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline before ==", "selector": "$[?@.a\n==@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 1 } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab before ==", "selector": "$[?@.a\t==@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 1 } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return before ==", "selector": "$[?@.a\r==@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 1 } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space after ==", "selector": "$[?@.a== @.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 1 } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline after ==", "selector": "$[?@.a==\n@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 1 } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab after ==", "selector": "$[?@.a==\t@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 1 } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return after ==", "selector": "$[?@.a==\r@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 1 } ], "result_paths": [ "$[0]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space before !=", "selector": "$[?@.a !=@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline before !=", "selector": "$[?@.a\n!=@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab before !=", "selector": "$[?@.a\t!=@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return before !=", "selector": "$[?@.a\r!=@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space after !=", "selector": "$[?@.a!= @.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline after !=", "selector": "$[?@.a!=\n@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab after !=", "selector": "$[?@.a!=\t@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return after !=", "selector": "$[?@.a!=\r@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space before <", "selector": "$[?@.a <@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline before <", "selector": "$[?@.a\n<@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab before <", "selector": "$[?@.a\t<@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return before <", "selector": "$[?@.a\r<@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space after <", "selector": "$[?@.a< @.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline after <", "selector": "$[?@.a<\n@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab after <", "selector": "$[?@.a<\t@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return after <", "selector": "$[?@.a<\r@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space before >", "selector": "$[?@.b >@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline before >", "selector": "$[?@.b\n>@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab before >", "selector": "$[?@.b\t>@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return before >", "selector": "$[?@.b\r>@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space after >", "selector": "$[?@.b> @.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline after >", "selector": "$[?@.b>\n@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab after >", "selector": "$[?@.b>\t@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return after >", "selector": "$[?@.b>\r@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result": [ { "a": 1, "b": 2 } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space before <=", "selector": "$[?@.a <=@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline before <=", "selector": "$[?@.a\n<=@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab before <=", "selector": "$[?@.a\t<=@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return before <=", "selector": "$[?@.a\r<=@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space after <=", "selector": "$[?@.a<= @.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline after <=", "selector": "$[?@.a<=\n@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab after <=", "selector": "$[?@.a<=\t@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return after <=", "selector": "$[?@.a<=\r@.b]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space before >=", "selector": "$[?@.b >=@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline before >=", "selector": "$[?@.b\n>=@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab before >=", "selector": "$[?@.b\t>=@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return before >=", "selector": "$[?@.b\r>=@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space after >=", "selector": "$[?@.b>= @.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline after >=", "selector": "$[?@.b>=\n@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab after >=", "selector": "$[?@.b>=\t@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return after >=", "selector": "$[?@.b>=\r@.a]", "document": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 }, { "a": 2, "b": 1 } ], "result": [ { "a": 1, "b": 1 }, { "a": 1, "b": 2 } ], "result_paths": [ "$[0]", "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space between logical not and test expression", "selector": "$[?! @.a]", "document": [ { "a": "a", "d": "e" }, { "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "d": "f" } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline between logical not and test expression", "selector": "$[?!\n@.a]", "document": [ { "a": "a", "d": "e" }, { "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "d": "f" } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab between logical not and test expression", "selector": "$[?!\t@.a]", "document": [ { "a": "a", "d": "e" }, { "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "d": "f" } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return between logical not and test expression", "selector": "$[?!\r@.a]", "document": [ { "a": "a", "d": "e" }, { "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "d": "f" } ], "result_paths": [ "$[1]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, space between logical not and parenthesized expression", "selector": "$[?! (@.a=='b')]", "document": [ { "a": "a", "d": "e" }, { "a": "b", "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "a": "a", "d": "e" }, { "a": "d", "d": "f" } ], "result_paths": [ "$[0]", "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, newline between logical not and parenthesized expression", "selector": "$[?!\n(@.a=='b')]", "document": [ { "a": "a", "d": "e" }, { "a": "b", "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "a": "a", "d": "e" }, { "a": "d", "d": "f" } ], "result_paths": [ "$[0]", "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, tab between logical not and parenthesized expression", "selector": "$[?!\t(@.a=='b')]", "document": [ { "a": "a", "d": "e" }, { "a": "b", "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "a": "a", "d": "e" }, { "a": "d", "d": "f" } ], "result_paths": [ "$[0]", "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, operators, return between logical not and parenthesized expression", "selector": "$[?!\r(@.a=='b')]", "document": [ { "a": "a", "d": "e" }, { "a": "b", "d": "f" }, { "a": "d", "d": "f" } ], "result": [ { "a": "a", "d": "e" }, { "a": "d", "d": "f" } ], "result_paths": [ "$[0]", "$[2]" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, space between root and bracket", "selector": "$ ['a']", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, newline between root and bracket", "selector": "$\n['a']", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, tab between root and bracket", "selector": "$\t['a']", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, return between root and bracket", "selector": "$\r['a']", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, space between bracket and bracket", "selector": "$['a'] ['b']", "document": { "a": { "b": "ab" } }, "result": [ "ab" ], "result_paths": [ "$['a']['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, newline between bracket and bracket", "selector": "$['a'] \n['b']", "document": { "a": { "b": "ab" } }, "result": [ "ab" ], "result_paths": [ "$['a']['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, tab between bracket and bracket", "selector": "$['a'] \t['b']", "document": { "a": { "b": "ab" } }, "result": [ "ab" ], "result_paths": [ "$['a']['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, return between bracket and bracket", "selector": "$['a'] \r['b']", "document": { "a": { "b": "ab" } }, "result": [ "ab" ], "result_paths": [ "$['a']['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, space between root and dot", "selector": "$ .a", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, newline between root and dot", "selector": "$\n.a", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, tab between root and dot", "selector": "$\t.a", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, return between root and dot", "selector": "$\r.a", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, space between dot and name", "selector": "$. a", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, newline between dot and name", "selector": "$.\na", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, tab between dot and name", "selector": "$.\ta", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, return between dot and name", "selector": "$.\ra", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, space between recursive descent and name", "selector": "$.. a", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, newline between recursive descent and name", "selector": "$..\na", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, tab between recursive descent and name", "selector": "$..\ta", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, return between recursive descent and name", "selector": "$..\ra", "invalid_selector": true, "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, space between bracket and selector", "selector": "$[ 'a']", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, newline between bracket and selector", "selector": "$[\n'a']", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, tab between bracket and selector", "selector": "$[\t'a']", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, return between bracket and selector", "selector": "$[\r'a']", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, space between selector and bracket", "selector": "$['a' ]", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, newline between selector and bracket", "selector": "$['a'\n]", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, tab between selector and bracket", "selector": "$['a'\t]", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, return between selector and bracket", "selector": "$['a'\r]", "document": { "a": "ab" }, "result": [ "ab" ], "result_paths": [ "$['a']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, space between selector and comma", "selector": "$['a' ,'b']", "document": { "a": "ab", "b": "bc" }, "result": [ "ab", "bc" ], "result_paths": [ "$['a']", "$['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, newline between selector and comma", "selector": "$['a'\n,'b']", "document": { "a": "ab", "b": "bc" }, "result": [ "ab", "bc" ], "result_paths": [ "$['a']", "$['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, tab between selector and comma", "selector": "$['a'\t,'b']", "document": { "a": "ab", "b": "bc" }, "result": [ "ab", "bc" ], "result_paths": [ "$['a']", "$['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, return between selector and comma", "selector": "$['a'\r,'b']", "document": { "a": "ab", "b": "bc" }, "result": [ "ab", "bc" ], "result_paths": [ "$['a']", "$['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, space between comma and selector", "selector": "$['a', 'b']", "document": { "a": "ab", "b": "bc" }, "result": [ "ab", "bc" ], "result_paths": [ "$['a']", "$['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, newline between comma and selector", "selector": "$['a',\n'b']", "document": { "a": "ab", "b": "bc" }, "result": [ "ab", "bc" ], "result_paths": [ "$['a']", "$['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, tab between comma and selector", "selector": "$['a',\t'b']", "document": { "a": "ab", "b": "bc" }, "result": [ "ab", "bc" ], "result_paths": [ "$['a']", "$['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, selectors, return between comma and selector", "selector": "$['a',\r'b']", "document": { "a": "ab", "b": "bc" }, "result": [ "ab", "bc" ], "result_paths": [ "$['a']", "$['b']" ], "tags": [ "whitespace" ] }, { "name": "whitespace, slice, space between start and colon", "selector": "$[1 :5:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, newline between start and colon", "selector": "$[1\n:5:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, tab between start and colon", "selector": "$[1\t:5:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, return between start and colon", "selector": "$[1\r:5:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, space between colon and end", "selector": "$[1: 5:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, newline between colon and end", "selector": "$[1:\n5:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, tab between colon and end", "selector": "$[1:\t5:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, return between colon and end", "selector": "$[1:\r5:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, space between end and colon", "selector": "$[1:5 :2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, newline between end and colon", "selector": "$[1:5\n:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, tab between end and colon", "selector": "$[1:5\t:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, return between end and colon", "selector": "$[1:5\r:2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, space between colon and step", "selector": "$[1:5: 2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, newline between colon and step", "selector": "$[1:5:\n2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, tab between colon and step", "selector": "$[1:5:\t2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] }, { "name": "whitespace, slice, return between colon and step", "selector": "$[1:5:\r2]", "document": [ 1, 2, 3, 4, 5, 6 ], "result": [ 2, 4 ], "result_paths": [ "$[1]", "$[3]" ], "tags": [ "index", "whitespace" ] } ] } hurl-7.1.0/src/jsonpath2/tests/cts.rs000064400000000000000000000217361046102023000156140ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! Compliance Test Suite //! https://github.com/jsonpath-standard/jsonpath-compliance-test-suite use std::fmt::{Display, Formatter}; use hurl_core::reader::Pos; use serde_json::json; use crate::jsonpath2::{ self, eval::NodeList, parser::{ParseError, ParseErrorKind}, }; #[derive(Clone, Debug, PartialEq, Eq)] pub struct TestCase { name: String, document: Option, selector: String, invalid_selector: bool, results: Vec, // contains several results when the order is non-deterministic } #[derive(Clone, Debug, PartialEq, Eq)] pub struct TestCaseError { testcase: TestCase, kind: TestCaseErrorKind, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum TestCaseErrorKind { UnexpectedParseError(ParseError), InvalidSelector, Eval(NodeList), } impl TestCase { #[allow(clippy::result_large_err)] pub fn run(&self) -> Result<(), TestCaseError> { let query = match jsonpath2::parse(&self.selector) { Ok(value) => { if self.invalid_selector { return Err(TestCaseError { testcase: self.clone(), kind: TestCaseErrorKind::InvalidSelector, }); } else { value } } Err(parse_error) => { if self.invalid_selector { return Ok(()); } else { return Err(TestCaseError { testcase: self.clone(), kind: TestCaseErrorKind::UnexpectedParseError(parse_error), }); } } }; let actual_result = query.eval(&self.document.clone().unwrap()); for result in &self.results { if *result == actual_result { return Ok(()); } } Err(TestCaseError { testcase: self.clone(), kind: TestCaseErrorKind::Eval(actual_result), }) } } impl Display for TestCaseError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let mut s = format!(">>> {}", self.testcase.name.clone()); match &self.kind { TestCaseErrorKind::UnexpectedParseError(_parse_error) => { s.push_str(&format!( "\ncan not parse valid selector <{}>", self.testcase.selector )); } TestCaseErrorKind::InvalidSelector => { s.push_str(&format!( "\nshould not parse the invalid selector <{}>", self.testcase.selector )); } TestCaseErrorKind::Eval(values) => { s.push_str(&format!( "\ndocument: {}", serde_json::to_string(&self.testcase.document.clone().unwrap()).unwrap() )); s.push_str(&format!("\nselector: {}", self.testcase.selector)); if self.testcase.results.len() == 1 { let expected = serde_json::to_string_pretty(&self.testcase.results.first().unwrap()) .unwrap(); s.push_str(&format!("\nexpected: {expected}")); } else { s.push_str("\nexpected &one of these):"); for result in &self.testcase.results { let expected = serde_json::to_string_pretty(result).unwrap(); s.push_str(&format!("\n {expected}")); } } let actual = serde_json::to_string_pretty(values).unwrap(); s.push_str(&format!("\nactual: {actual}")); } } write!(f, "{s}",) } } #[test] fn run_testcase() { // UnexpectedParseError let testcase = TestCase { name: "valid selector that fails".to_string(), document: None, selector: "xxx".to_string(), invalid_selector: false, results: vec![], }; let testcase_error = testcase.run().unwrap_err(); assert_eq!( testcase_error, TestCaseError { testcase, kind: TestCaseErrorKind::UnexpectedParseError(ParseError::new( Pos::new(1, 1), ParseErrorKind::Expecting("$".to_string()) )) } ); assert_eq!( testcase_error.to_string(), ">>> valid selector that fails\ncan not parse valid selector ".to_string() ); // InvalidSelector let testcase = TestCase { name: "invalid selector that succeeds".to_string(), document: None, selector: "$".to_string(), invalid_selector: true, results: vec![], }; let testcase_error = testcase.run().unwrap_err(); assert_eq!( testcase_error, TestCaseError { testcase, kind: TestCaseErrorKind::InvalidSelector } ); assert_eq!( testcase_error.to_string(), ">>> invalid selector that succeeds\nshould not parse the invalid selector <$>".to_string() ); // EvalError let testcase = TestCase { name: "valid eval that fails".to_string(), document: Some(json!({"name": "Bob"})), selector: "$.name".to_string(), invalid_selector: false, results: vec![vec![json!("Bill")]], }; let testcase_error = testcase.run().unwrap_err(); assert_eq!( testcase_error, TestCaseError { testcase, kind: TestCaseErrorKind::Eval(vec![json!("Bob")]) } ); assert_eq!( testcase_error.to_string(), ">>> valid eval that fails\ndocument: {\"name\":\"Bob\"}\nselector: $.name\nexpected: [\n \"Bill\"\n]\nactual: [\n \"Bob\"\n]".to_string() ); // Eval OK let test_case = TestCase { name: "valid eval that succeeed".to_string(), document: Some(json!({"name": "Bob"})), selector: "$.name".to_string(), invalid_selector: false, results: vec![vec![json!("Bob")]], }; assert!(test_case.run().is_ok()); } fn get_results(test: &serde_json::Value) -> Vec { if let Some(results) = test.get("results") { let values = results.as_array().unwrap(); values .iter() .map(|v| v.as_array().unwrap()) .cloned() .collect::>() } else if let Some(result) = test.get("result") { vec![result.as_array().unwrap().to_owned()] } else { vec![] } } fn parse_testcase(test: &serde_json::Value) -> TestCase { let name = test.get("name").unwrap().as_str().unwrap().to_owned(); let selector = test.get("selector").unwrap().as_str().unwrap().to_owned(); let invalid_selector = test.get("invalid_selector").is_some(); let document = test.get("document").cloned(); let results = get_results(test); TestCase { name, document, selector, invalid_selector, results, } } // double vec fn load_testcases() -> Vec { let content = include_str!("cts.json"); let data: serde_json::Value = serde_json::from_str(content).unwrap(); let tests = data.get("tests").unwrap().as_array().unwrap(); let mut testcases = vec![]; for test in tests { let testcase = parse_testcase(test); testcases.push(testcase); } testcases } #[test] fn run() { let testcases = load_testcases(); // TODO: Remove Limit when spec is fully implemented let testcases = testcases.iter().take(106); let count_total = testcases.len(); let errors = testcases .map(|test_case| test_case.run()) .collect::>>() .iter() .filter_map(|test_case| test_case.clone().err()) .collect::>(); if !errors.is_empty() { let count_failed = errors.len(); let count_passed = count_total - count_failed; let mut s = String::new(); for error in &errors { s.push_str(&error.to_string()); s.push_str("\n\n"); } s.push_str("RFC9535 Compliance tests:\n"); s.push_str(format!("Total: {count_total}\n").as_str()); s.push_str(format!("Passed: {count_passed}\n").as_str()); s.push_str(format!("Failed: {count_failed}\n").as_str()); panic!("{}", s); } } hurl-7.1.0/src/jsonpath2/tests/mod.rs000064400000000000000000000113731046102023000155760ustar 00000000000000use serde_json::json; /* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ mod cts; use crate::jsonpath2::{self, eval::NodeList}; fn store_value() -> serde_json::Value { json!( { "book": [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ], "bicycle": { "color": "red", "price": 19.95 } } ) } fn book_value() -> serde_json::Value { json!( [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ] ) } fn bicycle_value() -> serde_json::Value { serde_json::from_str( r#" { "color": "red", "price": 19.95 } "#, ) .unwrap() } fn book0_value() -> serde_json::Value { json!( { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }) } fn book1_value() -> serde_json::Value { json!( { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }) } fn book2_value() -> serde_json::Value { json!( { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }) } fn book3_value() -> serde_json::Value { json!({ "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 }) } fn eval(value: &serde_json::Value, query: &str) -> NodeList { let expr = jsonpath2::parse(query).unwrap(); expr.eval(value) } #[test] fn root_identifier() { assert_eq!(eval(&store_value(), "$"), vec![store_value()]); } #[test] fn child_segment() { assert_eq!(eval(&store_value(), "$['book']"), vec![book_value()]); assert_eq!(eval(&store_value(), "$.book"), vec![book_value()]); assert_eq!(eval(&store_value(), "$.book[0]"), vec![book0_value()]); assert_eq!( eval(&store_value(), "$.book[0].author"), vec![json!("Nigel Rees")] ); assert_eq!( eval(&store_value(), "$.*"), vec![bicycle_value(), book_value()] ); assert_eq!( eval(&store_value(), "$.book[:2]"), vec![book0_value(), book1_value()] ); assert_eq!( eval(&store_value(), "$.book[?@.isbn]"), vec![book2_value(), book3_value()] ); assert_eq!( eval(&store_value(), "$.book[0,1]"), vec![book0_value(), book1_value()] ); } #[test] fn descendant_segment() { assert_eq!( eval(&store_value(), "$..author"), vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ] ); assert_eq!(eval(&store_value(), "$..book[2]"), vec![book2_value()]); assert_eq!(eval(&store_value(), "$..book[-1]"), vec![book3_value()]); assert_eq!( eval(&store_value(), "$..book[:2]"), vec![book0_value(), book1_value()] ); } hurl-7.1.0/src/lib.rs000064400000000000000000000026311046102023000125100ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! This crate provides a function to run a Hurl formatted content. //! Hurl uses a plain text format to run and tests HTTP requests. The fully documented //! format is available at //! //! A Hurl sample: //! ```hurl //! # Get home: //! GET https://example.org //! HTTP 200 //! [Captures] //! csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)" //! //! //! # Do login! //! POST https://example.org/login?user=toto&password=1234 //! X-CSRF-TOKEN: {{csrf_token}} //! HTTP 302 //! ``` //! //! The main function of this crate is [`runner::run`]. //! //! This crate works on Windows, macOS and Linux. mod html; pub mod http; mod json; mod jsonpath; mod jsonpath2; pub mod output; #[doc(hidden)] pub mod parallel; pub mod pretty; pub mod report; pub mod runner; pub mod util; hurl-7.1.0/src/main.rs000064400000000000000000000276571046102023000127050ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ mod cli; mod run; use std::collections::HashSet; use std::io::prelude::*; use std::io::IsTerminal; use std::path::Path; use std::time::Instant; use std::{env, io, process, thread}; use hurl::report::{curl, html, json, junit, tap}; use hurl::runner; use hurl::runner::HurlResult; use hurl::util::redacted::Redact; use hurl_core::input::Input; use hurl_core::text; use crate::cli::options::{CliOptions, CliOptionsError, RunContext}; use crate::cli::{BaseLogger, CliError}; const EXIT_OK: i32 = 0; const EXIT_ERROR_COMMANDLINE: i32 = 1; const EXIT_ERROR_PARSING: i32 = 2; const EXIT_ERROR_RUNTIME: i32 = 3; const EXIT_ERROR_ASSERT: i32 = 4; const EXIT_ERROR_UNDEFINED: i32 = 127; /// Structure that stores the result of an Hurl file execution, and the content of the file. #[derive(Clone, Debug, PartialEq, Eq)] struct HurlRun { /// Source string for this [`HurlFile`] content: String, /// Content's source file filename: Input, hurl_result: HurlResult, } /// Executes Hurl entry point. fn main() { text::init_crate_colored(); // Construct the run context environment, this should be the sole place where we read // environment variables. The run context will be injected in functions that need to access // environment variables. // TODO: add `env::current_dir` to the run context let env_vars = env::vars().collect(); let stdin_term = io::stdin().is_terminal(); let stdout_term = io::stdout().is_terminal(); let stderr_term = io::stderr().is_terminal(); let ctx = RunContext::new(env_vars, stdin_term, stdout_term, stderr_term); let opts = match cli::options::parse(&ctx) { Ok(v) => v, Err(e) => match e { CliOptionsError::DisplayHelp(e) | CliOptionsError::DisplayVersion(e) => { print!("{e}"); process::exit(EXIT_OK); } _ => { eprintln!("{e}"); process::exit(EXIT_ERROR_COMMANDLINE); } }, }; // We create a basic logger that can just display info, warning or error generic messages. // We'll use a more advanced logger for rich error report when running Hurl files. let verbose = opts.verbose || opts.very_verbose || opts.interactive; let base_logger = BaseLogger::new(opts.color, verbose); let current_dir = env::current_dir(); let current_dir = unwrap_or_exit(current_dir, EXIT_ERROR_UNDEFINED, &base_logger); let current_dir = current_dir.as_path(); let start = Instant::now(); let runs = if opts.parallel { let available = unwrap_or_exit( thread::available_parallelism(), EXIT_ERROR_UNDEFINED, &base_logger, ); let workers_count = opts.jobs.unwrap_or(available.get()); base_logger.debug(&format!("Parallel run using {workers_count} workers")); run::run_par(&opts.input_files, current_dir, &opts, workers_count) } else { run::run_seq(&opts.input_files, current_dir, &opts) }; let runs = match runs { // Even in the presence of false assertions, `run::run_par` or `run::run_seq` return an `Ok` // result. The false assertions "errors" are displayed in these functions and are not considered // as program errors. Ok(r) => r, // So, we're dealing here with I/O errors: input reading, parsing etc... // We consider input read as "parsing" and don't have a specific exit code for the moment. Err(CliError::InputRead(msg)) => exit_with_error(&msg, EXIT_ERROR_PARSING, &base_logger), // In case of parsing error, there is no error because the display of parsing error has been // done in the execution of the Hurl files, inside the crates (and not in the main). Err(CliError::Parsing) => exit_with_error("", EXIT_ERROR_PARSING, &base_logger), Err(CliError::OutputWrite(msg)) => exit_with_error(&msg, EXIT_ERROR_RUNTIME, &base_logger), Err(CliError::GenericIO(msg)) => exit_with_error(&msg, EXIT_ERROR_PARSING, &base_logger), }; // Compute duration of the test here to not take reports writings into account. let duration = start.elapsed(); // Write HTML, JUnit, TAP reports on disk. if has_report(&opts) { let ret = export_results(&runs, &opts, &base_logger); unwrap_or_exit(ret, EXIT_ERROR_UNDEFINED, &base_logger); } if opts.test { let summary = cli::summary(&runs, duration); base_logger.info(summary.as_str()); } process::exit(exit_code(&runs)); } /// Unwraps a `result` or exit with message. fn unwrap_or_exit(result: Result, code: i32, logger: &BaseLogger) -> T where E: std::fmt::Display, { match result { Ok(v) => v, Err(e) => exit_with_error(&e.to_string(), code, logger), } } /// Prints an error message and exits the current process with an exit code. fn exit_with_error(message: &str, code: i32, logger: &BaseLogger) -> ! { if !message.is_empty() { logger.error(message); } process::exit(code); } /// Returns `true` if any kind of report should be created, `false` otherwise. fn has_report(opts: &CliOptions) -> bool { opts.curl_file.is_some() || opts.junit_file.is_some() || opts.tap_file.is_some() || opts.html_dir.is_some() || opts.json_report_dir.is_some() || opts.cookie_output_file.is_some() } /// Writes `runs` results on file, in HTML, TAP, JUnit or Cookie file format. fn export_results( runs: &[HurlRun], opts: &CliOptions, logger: &BaseLogger, ) -> Result<(), CliError> { // Compute secrets from the result. As secrets can be redacted during execution, we can't // consider only secrets introduced from cli, we have to get secrets produced during execution. // We remove identical secrets as there may be a lot of identical secrets (those that come // from the command line for instance) let secrets = runs .iter() .flat_map(|r| r.hurl_result.variables.secrets()) .collect::>(); let secrets = secrets.iter().map(|s| s.as_ref()).collect::>(); if let Some(file) = &opts.curl_file { create_curl_export(runs, file, &secrets)?; } if let Some(file) = &opts.junit_file { logger.debug(&format!("Writing JUnit report to {}", file.display())); create_junit_report(runs, file, &secrets)?; } if let Some(file) = &opts.tap_file { // TAP files doesn't need to be redacted, they don't expose any logs apart from files names. logger.debug(&format!("Writing TAP report to {}", file.display())); create_tap_report(runs, file)?; } if let Some(dir) = &opts.html_dir { logger.debug(&format!("Writing HTML report to {}", dir.display())); create_html_report(runs, dir, &secrets)?; } if let Some(dir) = &opts.json_report_dir { logger.debug(&format!("Writing JSON report to {}", dir.display())); create_json_report(runs, dir, &secrets)?; } if let Some(file) = &opts.cookie_output_file { logger.debug(&format!("Writing cookies to {}", file.display())); create_cookies_file(runs, file, &secrets)?; } Ok(()) } /// Creates an export of all curl commands for this run. fn create_curl_export(runs: &[HurlRun], filename: &Path, secrets: &[&str]) -> Result<(), CliError> { let results = runs.iter().map(|r| &r.hurl_result).collect::>(); curl::write_curl(&results, filename, secrets)?; Ok(()) } /// Creates a JUnit report for this run. fn create_junit_report( runs: &[HurlRun], filename: &Path, secrets: &[&str], ) -> Result<(), CliError> { let testcases = runs .iter() .map(|r| junit::Testcase::from(&r.hurl_result, &r.content, &r.filename)) .collect::>(); junit::write_report(filename, &testcases, secrets)?; Ok(()) } /// Creates a TAP report for this run. fn create_tap_report(runs: &[HurlRun], filename: &Path) -> Result<(), CliError> { let testcases = runs .iter() .map(|r| tap::Testcase::from(&r.hurl_result, &r.filename)) .collect::>(); tap::write_report(filename, &testcases)?; Ok(()) } /// Creates an HTML report for this run. fn create_html_report(runs: &[HurlRun], dir_path: &Path, secrets: &[&str]) -> Result<(), CliError> { // We ensure that the containing folder exists. let store_path = dir_path.join("store"); std::fs::create_dir_all(&store_path)?; let mut testcases = vec![]; for run in runs.iter() { let result = &run.hurl_result; let testcase = html::Testcase::from(result, &run.filename); testcase.write_html(&run.content, &result.entries, &store_path, secrets)?; testcases.push(testcase); } html::write_report(dir_path, &testcases)?; Ok(()) } /// Creates an JSON report for this run. fn create_json_report(runs: &[HurlRun], dir_path: &Path, secrets: &[&str]) -> Result<(), CliError> { // We ensure that the containing folder exists. let store_path = dir_path.join("store"); std::fs::create_dir_all(&store_path)?; let testcases = runs .iter() .map(|r| json::Testcase::new(&r.hurl_result, &r.content, &r.filename)) .collect::>(); let index_path = dir_path.join("report.json"); json::write_report(&index_path, &testcases, &store_path, secrets)?; Ok(()) } /// Returns an exit code for a list of HurlResult. fn exit_code(runs: &[HurlRun]) -> i32 { let mut count_errors_runner = 0; let mut count_errors_assert = 0; for run in runs.iter() { let errors = run.hurl_result.errors(); if errors.is_empty() { } else if errors.iter().filter(|(error, _)| !error.assert).count() == 0 { count_errors_assert += 1; } else { count_errors_runner += 1; } } if count_errors_runner > 0 { EXIT_ERROR_RUNTIME } else if count_errors_assert > 0 { EXIT_ERROR_ASSERT } else { EXIT_OK } } /// Export cookies for this run to `filename` file. /// /// The file format for the cookies is [Netscape cookie format](http://www.cookiecentral.com/faq/#3.5). fn create_cookies_file( runs: &[HurlRun], filename: &Path, secrets: &[&str], ) -> Result<(), CliError> { if let Err(err) = hurl::util::path::create_dir_all(filename) { return Err(CliError::GenericIO(format!( "Issue creating parent directories for {}: {err:?}", filename.display() ))); } let mut file = match std::fs::File::create(filename) { Err(why) => { return Err(CliError::GenericIO(format!( "Issue writing to {}: {why:?}", filename.display() ))); } Ok(file) => file, }; let mut s = r#"# Netscape HTTP Cookie File # This file was generated by Hurl "# .to_string(); if runs.is_empty() { return Err(CliError::GenericIO("Issue fetching results".to_string())); } for run in runs.iter() { s.push_str(&format!("# Cookies for file <{}>", run.filename)); s.push('\n'); for cookie in run.hurl_result.cookies.iter() { s.push_str(&cookie.to_netscape_str().redact(secrets)); s.push('\n'); } } if let Err(why) = file.write_all(s.as_bytes()) { return Err(CliError::GenericIO(format!( "Issue writing to {}: {why:?}", filename.display() ))); } Ok(()) } hurl-7.1.0/src/output/error.rs000064400000000000000000000051701046102023000144340ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::ast::SourceInfo; use hurl_core::error::DisplaySourceError; use hurl_core::text::{Style, StyledString}; use crate::http::HttpError; #[derive(Clone, Debug, PartialEq, Eq)] pub struct OutputError { pub source_info: SourceInfo, pub kind: OutputErrorKind, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum OutputErrorKind { Http(HttpError), Binary, Io(String), } impl OutputError { pub fn new(source_info: SourceInfo, kind: OutputErrorKind) -> OutputError { OutputError { source_info, kind } } } /// Textual Output for runner errors impl DisplaySourceError for OutputError { fn source_info(&self) -> SourceInfo { self.source_info } fn description(&self) -> String { match &self.kind { OutputErrorKind::Http(http_error) => http_error.description(), OutputErrorKind::Binary => "Binary Error".to_string(), OutputErrorKind::Io(_) => "IO Error".to_string(), } } fn fixme(&self, content: &[&str]) -> StyledString { match &self.kind { OutputErrorKind::Http(http_error) => { let message = http_error.message(); let message = hurl_core::error::add_carets(&message, self.source_info, content); color_red(&message) } OutputErrorKind::Binary => { let message = "Binary output can mess up your terminal. Use \"--output -\" to tell Hurl to output it to your terminal anyway, or consider \"--output\" to save to a file."; let message = hurl_core::error::add_carets(message, self.source_info, content); color_red(&message) } OutputErrorKind::Io(message) => { let message = hurl_core::error::add_carets(message, self.source_info, content); color_red(&message) } } } } fn color_red(message: &str) -> StyledString { let mut s = StyledString::new(); s.push_with(message, Style::new().red().bold()); s } hurl-7.1.0/src/output/json.rs000064400000000000000000000041001046102023000142440ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::io; use hurl_core::input::Input; use crate::runner::{HurlResult, Output}; use crate::util::term::Stdout; /// Writes the `hurl_result` JSON representation to the file `filename_out`. /// /// If `filename_out` is `None`, stdout is used. If `append` is true, any existing file will /// be appended instead of being truncated. The original `content` of the Hurl file and the /// source `filename_in` is necessary in order to construct error fields with column, line number /// etc... when processing failed asserts and captures. pub fn write_json( hurl_result: &HurlResult, content: &str, filename_in: &Input, filename_out: Option<&Output>, stdout: &mut Stdout, append: bool, ) -> Result<(), io::Error> { let response_dir = None; // Secrets are only redacted from standard error and reports. In this case, we want to output a // response in a structured way. We do not change the value of the response output as it may be // used for processing, contrary to the standard error that should be used for debug/log/messages. let secrets = []; let json_result = hurl_result.to_json(content, filename_in, response_dir, &secrets)?; let serialized = serde_json::to_string(&json_result)?; let bytes = format!("{serialized}\n"); let bytes = bytes.into_bytes(); match filename_out { Some(out) => out.write(&bytes, stdout, append)?, None => Output::Stdout.write(&bytes, stdout, append)?, } Ok(()) } hurl-7.1.0/src/output/mod.rs000064400000000000000000000022201046102023000140530ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! Serialize a Hurl run result to a file. //! //! There are two supported serialisation: //! //! - JSON: the whole run is serialized to JSON (like the [HAR](https://en.wikipedia.org/wiki/HAR_(file_format)) format) //! [`self::json::write_json`] //! - raw: the last response of a run is serialized to a file. The body can be automatically uncompress //! or written as it [`self::raw::write_last_body`] mod error; mod json; mod raw; pub use self::error::OutputError; pub use self::json::write_json; pub use self::raw::write_last_body; hurl-7.1.0/src/output/raw.rs000064400000000000000000000234561046102023000141030ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use super::error::OutputErrorKind; use super::OutputError; use crate::pretty; use crate::pretty::json::Color; use crate::pretty::PrettyMode; use crate::runner::{HurlResult, Output}; use crate::util::term::Stdout; use std::cmp::min; use std::io::IsTerminal; /// Writes the `hurl_result` last response to the file `filename_out`. /// /// When `include_headers` is true, the last HTTP response headers are written before the body response. /// When `filename_out` is `None`, standard output is used. /// When `append` is true, any existing file will be appended instead of being truncated. /// The body can pe prettified base on `pretty` value. pub fn write_last_body( hurl_result: &HurlResult, include_headers: bool, color: bool, pretty: PrettyMode, filename_out: Option<&Output>, stdout: &mut Stdout, append: bool, ) -> Result<(), OutputError> { // Get the last call of the Hurl result. let Some(last_entry) = &hurl_result.entries.last() else { return Ok(()); }; let Some(call) = &last_entry.calls.last() else { return Ok(()); }; let response = &call.response; let source_info = last_entry.source_info; let mut output = Vec::new(); // If include options is set, we output the HTTP response headers with status and version // (to mimic curl outputs) if include_headers { let text = response.get_status_line_headers(color); output.append(&mut text.into_bytes()); output.push(b'\n'); } let body_bytes = if last_entry.compressed { &match response.uncompress_body() { Ok(b) => b, Err(e) => { let source_info = last_entry.source_info; let kind = OutputErrorKind::Http(e); return Err(OutputError::new(source_info, kind)); } } } else { &response.body }; // Prettify only JSON-like response for the moment. let pretty = match pretty { PrettyMode::Automatic => response.is_json(), PrettyMode::Force => true, PrettyMode::None => false, }; if pretty { let color_pretty = if color { Color::Ansi } else { Color::NoColor }; match pretty::format(body_bytes, color_pretty, &mut output) { Ok(_) => {} Err(_) => { // We've an error trying to pretty print response output, we silently fail and // fallback on non prettifying. return write_last_body( hurl_result, include_headers, color, PrettyMode::None, filename_out, stdout, append, ); } } } else { output.extend_from_slice(body_bytes); } // We replicate curl's checks for binary output: a warning is displayed when user hasn't // used `--output` option and the response is considered as a binary content. If user has used // `--output` whether to save to a file, or to redirect output to standard output (`--output -`) // we don't display any warning. match filename_out { None => { if std::io::stdout().is_terminal() && is_binary(&output) { let kind = OutputErrorKind::Binary; return Err(OutputError::new(source_info, kind)); } Output::Stdout.write(&output, stdout, append).map_err(|e| { let kind = OutputErrorKind::Io(e.to_string()); OutputError::new(source_info, kind) })?; } Some(out) => out.write(&output, stdout, append).map_err(|e| { let filename = if let Output::File(filename) = out { filename.display().to_string() } else { "stdout".to_string() }; let kind = OutputErrorKind::Io(format!("{filename} can not be written ({e})")); OutputError::new(source_info, kind) })?, } Ok(()) } /// Returns `true` if `bytes` is a binary content, false otherwise. /// /// For the implementation, we use a simple heuristic on the buffer: just check the presence of NULL /// in the first 2000 bytes to determine if the content if binary or not. /// /// See /// and fn is_binary(bytes: &[u8]) -> bool { let len = min(2000, bytes.len()); for c in &bytes[..len] { if *c == 0 { return true; } } false } #[cfg(test)] mod tests { use std::str::FromStr; use std::time::Duration; use crate::http::{Call, Header, HeaderVec, HttpVersion, Request, Response, Url}; use crate::output::write_last_body; use crate::pretty::PrettyMode; use crate::runner::{EntryResult, HurlResult, Output}; use crate::util::term::{Stdout, WriteMode}; use hurl_core::ast::SourceInfo; use hurl_core::reader::Pos; use hurl_core::types::Index; fn default_response() -> Response { Response { version: HttpVersion::Http10, status: 200, headers: HeaderVec::new(), body: vec![], duration: Default::default(), url: Url::from_str("http://localhost").unwrap(), certificate: None, ip_addr: Default::default(), } } fn hurl_result_json() -> HurlResult { let mut headers = HeaderVec::new(); headers.push(Header::new("x-foo", "xxx")); headers.push(Header::new("x-bar", "yyy0")); headers.push(Header::new("x-bar", "yyy1")); headers.push(Header::new("x-bar", "yyy2")); headers.push(Header::new("x-baz", "zzz")); HurlResult { entries: vec![ EntryResult { entry_index: Index::new(1), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), calls: vec![Call { request: Request { url: Url::from_str("https://foo.com").unwrap(), method: "GET".to_string(), headers: HeaderVec::new(), body: vec![], }, response: default_response(), timings: Default::default(), }], ..Default::default() }, EntryResult { entry_index: Index::new(2), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), calls: vec![Call { request: Request { url: Url::from_str("https://bar.com").unwrap(), method: "GET".to_string(), headers: HeaderVec::new(), body: vec![], }, response: default_response(), timings: Default::default(), }], ..Default::default() }, EntryResult { entry_index: Index::new(3), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), calls: vec![Call { request: Request { url: Url::from_str("https://baz.com").unwrap(), method: "GET".to_string(), headers: HeaderVec::new(), body: vec![], }, response: Response { version: HttpVersion::Http3, status: 204, headers, body: b"{\"say\": \"Hello World!\"}".into(), duration: Default::default(), url: Url::from_str("https://baz.com").unwrap(), certificate: None, ip_addr: Default::default(), }, timings: Default::default(), }], ..Default::default() }, ], duration: Duration::from_millis(100), success: true, ..Default::default() } } #[test] fn write_last_body_with_headers() { let result = hurl_result_json(); let include_header = true; let color = false; let pretty = PrettyMode::None; let output = Some(Output::Stdout); let mut stdout = Stdout::new(WriteMode::Buffered); write_last_body( &result, include_header, color, pretty, output.as_ref(), &mut stdout, true, ) .unwrap(); let stdout = String::from_utf8(stdout.buffer().to_vec()).unwrap(); assert_eq!( stdout, "HTTP/3 204\n\ x-foo: xxx\n\ x-bar: yyy0\n\ x-bar: yyy1\n\ x-bar: yyy2\n\ x-baz: zzz\n\ \n\ {\"say\": \"Hello World!\"}" ); } } hurl-7.1.0/src/parallel/error.rs000064400000000000000000000015651046102023000146740ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ /// Error triggered when running a [`crate::parallel::job::Job`]. pub enum JobError { /// An error has occurred while reading input. InputRead(String), Parsing, /// An error has occurred while writing to output. OutputWrite(String), } hurl-7.1.0/src/parallel/job.rs000064400000000000000000000145231046102023000143130ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::input::Input; use hurl_core::types::Count; use crate::runner::{HurlResult, RunnerOptions, VariableSet}; use crate::util::logger::LoggerOptions; /// Represents the job to run. A job instance groups the input data to execute, and has no methods /// associated to it. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Job { /// The Hurl source content pub filename: Input, /// The options to run this file. pub runner_options: RunnerOptions, /// Set of variables injected in the Hurl file pub variables: VariableSet, /// The logger options for this run pub logger_options: LoggerOptions, /// The job 0-based index in the jobs list pub seq: usize, } impl Job { /// Creates a new job. pub fn new( filename: &Input, seq: usize, runner_options: &RunnerOptions, variables: &VariableSet, logger_options: &LoggerOptions, ) -> Self { Job { filename: filename.clone(), runner_options: runner_options.clone(), variables: variables.clone(), logger_options: logger_options.clone(), seq, } } } pub struct JobResult { /// The job corresponding to this job result. pub job: Job, /// The source content of the job. pub content: String, /// The result of execution of the job. pub hurl_result: HurlResult, } impl JobResult { /// Creates a new job result. pub fn new(job: Job, content: String, hurl_result: HurlResult) -> Self { JobResult { job, content, hurl_result, } } } /// A job queue to manage a queue of [`Job`]. /// /// The job queue implements [`Iterator`] trait, and can return a new job to use each time its /// `next` method is called. This queue can repeat its input sequence a certain number of times, or /// can loop forever. pub struct JobQueue<'job> { /// The input jobs list. jobs: &'job [Job], /// Current index of the job, referencing the input job list. index: usize, /// Repeat mode of this queue (finite or infinite). repeat: Count, /// Current index of the repeat. repeat_index: usize, } impl<'job> JobQueue<'job> { /// Create a new queue, with a list of `jobs` and a `repeat` mode. pub fn new(jobs: &'job [Job], repeat: Count) -> Self { JobQueue { jobs, index: 0, repeat, repeat_index: 0, } } /// Returns the effective total number of jobs. pub fn jobs_count(&self) -> Count { match self.repeat { Count::Finite(n) => Count::Finite(self.jobs.len() * n), Count::Infinite => Count::Infinite, } } /// Returns a new job at the given `index`. fn job_at(&self, index: usize) -> Job { let mut job = self.jobs[index].clone(); // When we're repeating a sequence, we clone an original job and give it a proper // sequence number relative to the current `repeat_index`. job.seq = self.jobs[index].seq + (self.jobs.len() * self.repeat_index); job } } impl Iterator for JobQueue<'_> { type Item = Job; fn next(&mut self) -> Option { if self.index >= self.jobs.len() { self.repeat_index = self.repeat_index.checked_add(1).unwrap_or(0); match self.repeat { Count::Finite(n) => { if self.repeat_index >= n { None } else { self.index = 1; Some(self.job_at(0)) } } Count::Infinite => { self.index = 1; Some(self.job_at(0)) } } } else { self.index += 1; Some(self.job_at(self.index - 1)) } } } #[cfg(test)] mod tests { use hurl_core::input::Input; use hurl_core::types::Count; use crate::parallel::job::{Job, JobQueue}; use crate::runner::{RunnerOptionsBuilder, VariableSet}; use crate::util::logger::LoggerOptionsBuilder; fn new_job(file: &str, index: usize) -> Job { let variables = VariableSet::new(); let runner_options = RunnerOptionsBuilder::default().build(); let logger_options = LoggerOptionsBuilder::default().build(); Job::new( &Input::new(file), index, &runner_options, &variables, &logger_options, ) } #[test] fn job_queue_is_finite() { let jobs = [ new_job("a.hurl", 0), new_job("b.hurl", 1), new_job("c.hurl", 2), ]; let mut queue = JobQueue::new(&jobs, Count::Finite(2)); assert_eq!(queue.next(), Some(new_job("a.hurl", 0))); assert_eq!(queue.next(), Some(new_job("b.hurl", 1))); assert_eq!(queue.next(), Some(new_job("c.hurl", 2))); assert_eq!(queue.next(), Some(new_job("a.hurl", 3))); assert_eq!(queue.next(), Some(new_job("b.hurl", 4))); assert_eq!(queue.next(), Some(new_job("c.hurl", 5))); assert_eq!(queue.next(), None); assert_eq!(queue.jobs_count(), Count::Finite(6)); } #[test] fn input_queue_is_infinite() { let jobs = [new_job("foo.hurl", 0)]; let mut queue = JobQueue::new(&jobs, Count::Infinite); assert_eq!(queue.next(), Some(new_job("foo.hurl", 0))); assert_eq!(queue.next(), Some(new_job("foo.hurl", 1))); assert_eq!(queue.next(), Some(new_job("foo.hurl", 2))); assert_eq!(queue.next(), Some(new_job("foo.hurl", 3))); assert_eq!(queue.next(), Some(new_job("foo.hurl", 4))); // etc... assert_eq!(queue.jobs_count(), Count::Infinite); } } hurl-7.1.0/src/parallel/message.rs000064400000000000000000000102741046102023000151640ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::util::term::{Stderr, Stdout}; use hurl_core::types::Index; use std::io; use super::job::{Job, JobResult}; use super::worker::WorkerId; /// Represents a message sent from the worker to the runner (running on the main thread). #[allow(clippy::large_enum_variant)] pub enum WorkerMessage { /// Error raised when the file can't be read. InputReadError(InputReadErrorMsg), /// Error raised when the file isn't a valid Hurl content. ParsingError(ParsingErrorMsg), /// Sent when the Hurl file is in progress (file has been parsed and HTTP exchanges have started). Running(RunningMsg), /// Sent when the Hurl file is completed, whether successful or failed. Completed(CompletedMsg), } /// A message sent from worker to runner when the input file can't be read. pub struct InputReadErrorMsg { /// Identifier of the worker sending this message. #[allow(dead_code)] pub worker_id: WorkerId, /// Job originator of this message. pub job: Job, /// Inner error that has triggered this message. pub error: io::Error, } impl InputReadErrorMsg { /// Creates a new I/O error message. pub fn new(worker_id: WorkerId, job: &Job, error: io::Error) -> Self { InputReadErrorMsg { worker_id, job: job.clone(), error, } } } /// A message sent from worker to runner when the input file can't be parsed. pub struct ParsingErrorMsg { /// Identifier of the worker sending this message. #[allow(dead_code)] pub worker_id: WorkerId, /// Job originator of this message. #[allow(dead_code)] pub job: Job, /// Standard error of the worker for this job. pub stderr: Stderr, } impl ParsingErrorMsg { /// Creates a new parsing error message. pub fn new(worker_id: WorkerId, job: &Job, stderr: &Stderr) -> Self { ParsingErrorMsg { worker_id, job: job.clone(), stderr: stderr.clone(), } } } /// A message sent from worker to runner at regular time to inform that the job is being run. pub struct RunningMsg { /// Identifier of the worker sending this message. pub worker_id: WorkerId, /// Job originator of this message. pub job: Job, /// Index of the current entry. pub current_entry: Index, /// Index of the last entry to be run. pub last_entry: Index, /// Number of actual retries pub retry_count: usize, } impl RunningMsg { /// Creates a new running message: the job is in progress. pub fn new( worker_id: WorkerId, job: &Job, current_entry: Index, last_entry: Index, retry_count: usize, ) -> Self { RunningMsg { worker_id, job: job.clone(), current_entry, last_entry, retry_count, } } } /// A message sent from worker to runner when a Hurl file has completed, whether successful or not. pub struct CompletedMsg { /// Identifier of the worker sending this message. pub worker_id: WorkerId, /// Result execution of the originator job, can successful or failed. pub result: JobResult, /// Standard output of the worker for this job. pub stdout: Stdout, /// Standard error of the worker for this job. pub stderr: Stderr, } impl CompletedMsg { /// Creates a new completed message: the job has completed, successfully or not. pub fn new(worker_id: WorkerId, result: JobResult, stdout: Stdout, stderr: Stderr) -> Self { CompletedMsg { worker_id, result, stdout, stderr, } } } hurl-7.1.0/src/parallel/mod.rs000064400000000000000000000013671046102023000143220ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! Run Hurl files in parallel (experimental). pub mod error; pub mod job; mod message; mod progress; pub mod runner; mod worker; hurl-7.1.0/src/parallel/progress.rs000064400000000000000000000460521046102023000154070ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::time::{Duration, Instant}; use super::job::JobResult; use super::runner::WorkerState; use super::worker::Worker; use crate::util::term::Stderr; use hurl_core::text::{Format, Style, StyledString}; use hurl_core::types::{Count, Index}; /// A progress reporter to display advancement of parallel runs execution in test mode. pub struct ParProgress { /// The maximum number of running workers displayed in the progress bar. max_running_displayed: usize, /// Mode of the progress reporter mode: Mode, /// The standard error format for message: ANSI or plain. format: Format, /// The maximum width of the progress string, in chars. max_width: Option, /// Save last progress bar refresh to limits flickering. throttle: Throttle, } #[derive(Copy, Clone)] pub enum Mode { /// Run without --test Default, /// Run with --test and with a progress bar TestWithProgress, /// Run with --test and no progress bar TestWithoutProgress, } /// The minimum duration between two progress bar redraw (to avoid flickering). const UPDATE_INTERVAL: Duration = Duration::from_millis(100); /// The minimum duration for the progress bar to be throttle (some delay to let the UI stabilize) const FIRST_THROTTLE: Duration = Duration::from_millis(16); impl ParProgress { /// Creates a new instance. pub fn new( max_running_displayed: usize, mode: Mode, color: bool, max_width: Option, ) -> Self { let format = if color { Format::Ansi } else { Format::Plain }; ParProgress { max_running_displayed, mode, format, max_width, throttle: Throttle::new(UPDATE_INTERVAL, FIRST_THROTTLE), } } /// Clear the progress bar. pub fn clear_progress_bar(&self, stderr: &mut Stderr) { if !matches!(self.mode, Mode::TestWithProgress) { return; } stderr.clear_progress_bar(); } /// Displays progression of `workers` on standard error `stderr`. /// /// This method is called on the parallel runner thread (usually the main thread). pub fn update_progress_bar( &mut self, workers: &[(Worker, WorkerState)], completed: usize, total: Count, stderr: &mut Stderr, ) { if !matches!(self.mode, Mode::TestWithProgress) { return; } self.throttle.update(); let Some(progress) = build_progress( workers, completed, total, self.max_running_displayed, self.format, self.max_width, ) else { return; }; stderr.set_progress_bar(&progress); } /// Displays the completion of a job `result`. pub fn print_completed(&mut self, result: &JobResult, stderr: &mut Stderr) { if matches!(self.mode, Mode::Default) { return; } let count = result .hurl_result .entries .iter() .flat_map(|r| &r.calls) .count(); let duration = result.hurl_result.duration.as_millis(); let filename = result.job.filename.to_string(); let mut message = StyledString::new(); if result.hurl_result.success { message.push_with("Success", Style::new().green().bold()); } else { message.push_with("Failure", Style::new().red().bold()); }; message.push(" "); message.push_with(&filename, Style::new().bold()); message.push(&format!(" ({count} request(s) in {duration} ms)")); let message = message.to_string(self.format); stderr.eprintln(&message); } /// Returns `true` if there has been sufficient time elapsed since the last progress bar /// refresh, `false` otherwise. pub fn can_update(&mut self) -> bool { self.throttle.allowed() } /// For the next progress bar update to be effectively drawn. pub fn force_next_update(&mut self) { self.throttle.reset(); } } impl Mode { pub fn new(test: bool, progress_bar: bool) -> Self { match (test, progress_bar) { (true, true) => Mode::TestWithProgress, (true, false) => Mode::TestWithoutProgress, _ => Mode::Default, } } } /// Records the instant when a progress bar is refreshed on the terminal. /// We don't want to update the progress bar too often as it can cause excessive performance loss /// just putting stuff onto the terminal. We also want to avoid flickering by not drawing anything /// that goes away too quickly. struct Throttle { /// Creation time of the progress. start: Instant, /// Last time the progress bar has be refreshed on the terminal. last_update: Option, /// Refresh interval interval: Duration, /// First interval of non throttle to let the UI initialize first_throttle: Duration, } impl Throttle { /// Creates a new instances. fn new(interval: Duration, first_throttle: Duration) -> Self { Throttle { start: Instant::now(), last_update: None, interval, first_throttle, } } /// Returns `true` if there has been sufficient time elapsed since the last refresh. fn allowed(&self) -> bool { match self.last_update { None => true, Some(update) => update.elapsed() >= self.interval, } } fn update(&mut self) { if self.start.elapsed() < self.first_throttle { return; } self.last_update = Some(Instant::now()); } fn reset(&mut self) { self.last_update = None; } } /// Returns a progress string, given a list of `workers`, a number of `completed` jobs and the /// total number of jobs. `total` is the total number of files to execute. /// /// `max_running_displayed` is used to limit the number of running progress bar. If more jobs are /// running, a label "...x more" is displayed. /// `format` is the format of the progress string (ANSI or plain). /// The progress string is wrapped with new lines at width `max_width`. fn build_progress( workers: &[(Worker, WorkerState)], completed: usize, total: Count, max_running_displayed: usize, format: Format, max_width: Option, ) -> Option { // Select the running workers to be displayed let mut workers = workers .iter() .filter(|(_, state)| matches!(state, WorkerState::Running { .. })) .collect::>(); if workers.is_empty() { return None; } // We sort the running workers by job sequence id, this way a given job will be displayed // on the same line, independently of the worker id. workers.sort_unstable_by_key(|(_, state)| match state { WorkerState::Running { job, .. } => job.seq, WorkerState::Idle => usize::MAX, }); let running = workers.len(); // We keep a reasonable number of worker to displayed, from the oldest to the newest. workers.truncate(max_running_displayed); // Computes maximum size of the string "[current request] / [nb of request]" to left align // the column. let max = workers .iter() .map(|(_, state)| match state { WorkerState::Running { last_entry, .. } => last_entry.get(), WorkerState::Idle => 0, }) .max() .unwrap(); let max_completed_width = 2 * (((max as f64).log10() as usize) + 1) + 1; // Construct all the progress strings let mut all_progress = String::new(); let progress = match total { Count::Finite(total) => { let percent = (completed as f64 * 100.0 / total as f64) as usize; format!("Executed files: {completed}/{total} ({percent}%)\n") } Count::Infinite => format!("Executed files: {completed}\n"), }; // We don't wrap this string for the moment, there is low chance to overlap the maximum width // of the terminal. all_progress.push_str(&progress); for (_, state) in &workers { if let WorkerState::Running { job, current_entry, last_entry, retry_count, } = state { let requests = format!("{current_entry}/{last_entry}"); let padding = " ".repeat(max_completed_width - requests.len()); let bar = progress_bar(*current_entry, *last_entry); let mut progress = StyledString::new(); progress.push(&bar); progress.push(&padding); progress.push(" "); progress.push_with("Running", Style::new().cyan().bold()); progress.push(" "); progress.push_with(&job.filename.to_string(), Style::new().bold()); if *retry_count > 0 { let retry = format!("(retry {})", retry_count); progress.push(" "); progress.push_with(&retry, Style::new().yellow()); } progress.push("\n"); // We wrap the progress string with new lines if necessary if let Some(max_width) = max_width { if progress.len() >= max_width { progress = progress.wrap(max_width); } } let progress = progress.to_string(format); all_progress.push_str(&progress); } } // If the number of running workers is greater that those displayed, we add the remaining // number of not displayed running. if running > max_running_displayed { all_progress.push_str(&format!("...{} more\n", running - max_running_displayed)); } Some(all_progress) } /// Returns the progress bar of a single operation with the current `index`. fn progress_bar(current: Index, last: Index) -> String { const WIDTH: usize = 24; // We report the number of items already processed. let progress = current.to_zero_based() as f64 / last.get() as f64; let col = (progress * WIDTH as f64) as usize; let completed = if col > 0 { "=".repeat(col) } else { String::new() }; let void = " ".repeat(WIDTH - col - 1); format!("[{completed}>{void}] {current}/{last}") } #[cfg(test)] mod tests { use std::sync::{mpsc, Arc, Mutex}; use crate::parallel::job::Job; use crate::parallel::progress::{build_progress, progress_bar}; use crate::parallel::runner::WorkerState; use crate::parallel::worker::{Worker, WorkerId}; use crate::runner::{RunnerOptionsBuilder, VariableSet}; use crate::util::logger::LoggerOptionsBuilder; use hurl_core::input::Input; use hurl_core::text::Format; use hurl_core::types::{Count, Index}; fn new_workers() -> (Worker, Worker, Worker, Worker, Worker) { let (tx_out, _) = mpsc::channel(); let (_, rx_in) = mpsc::channel(); let rx_in = Arc::new(Mutex::new(rx_in)); let w0 = Worker::new(WorkerId::from(0), &tx_out, &rx_in); let w1 = Worker::new(WorkerId::from(1), &tx_out, &rx_in); let w2 = Worker::new(WorkerId::from(2), &tx_out, &rx_in); let w3 = Worker::new(WorkerId::from(3), &tx_out, &rx_in); let w4 = Worker::new(WorkerId::from(4), &tx_out, &rx_in); (w0, w1, w2, w3, w4) } fn new_jobs() -> Vec { let variables = VariableSet::new(); let runner_options = RunnerOptionsBuilder::default().build(); let logger_options = LoggerOptionsBuilder::default().build(); let files = [ "a.hurl", "b.hurl", "c.hurl", "d.hurl", "e.hurl", "f.hurl", "g.hurl", ]; files .iter() .enumerate() .map(|(index, file)| { Job::new( &Input::new(file), index, &runner_options, &variables, &logger_options, ) }) .collect() } fn new_running_state( job: &Job, current_entry: Index, last_entry: Index, retry_count: usize, ) -> WorkerState { WorkerState::Running { job: job.clone(), current_entry, last_entry, retry_count, } } #[test] fn all_workers_running() { let (w0, w1, w2, w3, w4) = new_workers(); let jobs = new_jobs(); let completed = 75; let total = Count::Finite(100); let max_displayed = 3; let mut workers = vec![ (w0, WorkerState::Idle), (w1, WorkerState::Idle), (w2, WorkerState::Idle), (w3, WorkerState::Idle), (w4, WorkerState::Idle), ]; let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert!(progress.is_none()); workers[0].1 = new_running_state(&jobs[0], Index::new(1), Index::new(10), 0); workers[1].1 = new_running_state(&jobs[1], Index::new(1), Index::new(2), 0); workers[2].1 = new_running_state(&jobs[2], Index::new(1), Index::new(5), 0); workers[3].1 = new_running_state(&jobs[3], Index::new(1), Index::new(7), 0); workers[4].1 = new_running_state(&jobs[4], Index::new(1), Index::new(4), 0); let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [> ] 1/10 Running a.hurl\n\ [> ] 1/2 Running b.hurl\n\ [> ] 1/5 Running c.hurl\n\ ...2 more\n\ " ); workers[0].1 = new_running_state(&jobs[0], Index::new(6), Index::new(10), 0); workers[1].1 = new_running_state(&jobs[1], Index::new(2), Index::new(2), 0); workers[2].1 = new_running_state(&jobs[2], Index::new(3), Index::new(5), 0); workers[3].1 = new_running_state(&jobs[3], Index::new(4), Index::new(7), 0); workers[4].1 = new_running_state(&jobs[4], Index::new(2), Index::new(4), 0); let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [============> ] 6/10 Running a.hurl\n\ [============> ] 2/2 Running b.hurl\n\ [=========> ] 3/5 Running c.hurl\n\ ...2 more\n\ " ); workers[0].1 = new_running_state(&jobs[0], Index::new(10), Index::new(10), 0); workers[1].1 = new_running_state(&jobs[5], Index::new(1), Index::new(6), 0); workers[2].1 = new_running_state(&jobs[2], Index::new(5), Index::new(5), 0); workers[3].1 = new_running_state(&jobs[3], Index::new(6), Index::new(7), 0); workers[4].1 = new_running_state(&jobs[4], Index::new(3), Index::new(4), 0); let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [=====================> ] 10/10 Running a.hurl\n\ [===================> ] 5/5 Running c.hurl\n\ [=================> ] 6/7 Running d.hurl\n\ ...2 more\n\ " ); workers[0].1 = WorkerState::Idle; workers[1].1 = new_running_state(&jobs[5], Index::new(3), Index::new(6), 0); workers[2].1 = WorkerState::Idle; workers[3].1 = WorkerState::Idle; workers[4].1 = new_running_state(&jobs[4], Index::new(4), Index::new(4), 0); let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [==================> ] 4/4 Running e.hurl\n\ [========> ] 3/6 Running f.hurl\n\ " ); workers[0].1 = WorkerState::Idle; workers[1].1 = new_running_state(&jobs[5], Index::new(6), Index::new(6), 0); workers[2].1 = WorkerState::Idle; workers[3].1 = WorkerState::Idle; workers[4].1 = WorkerState::Idle; let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [====================> ] 6/6 Running f.hurl\n\ " ); workers[0].1 = WorkerState::Idle; workers[1].1 = new_running_state(&jobs[5], Index::new(6), Index::new(6), 1); workers[2].1 = WorkerState::Idle; workers[3].1 = WorkerState::Idle; workers[4].1 = WorkerState::Idle; let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [====================> ] 6/6 Running f.hurl (retry 1)\n\ " ); } #[rustfmt::skip] #[test] fn test_progress_bar() { // Progress strings with 20 entries: assert_eq!(progress_bar(Index::new(1), Index::new(20)), "[> ] 1/20"); assert_eq!(progress_bar(Index::new(2), Index::new(20)), "[=> ] 2/20"); assert_eq!(progress_bar(Index::new(5), Index::new(20)), "[====> ] 5/20"); assert_eq!(progress_bar(Index::new(10), Index::new(20)), "[==========> ] 10/20"); assert_eq!(progress_bar(Index::new(15), Index::new(20)), "[================> ] 15/20"); assert_eq!(progress_bar(Index::new(20), Index::new(20)), "[======================> ] 20/20"); // Progress strings with 3 entries: assert_eq!(progress_bar(Index::new(1), Index::new(3)), "[> ] 1/3"); assert_eq!(progress_bar(Index::new(2), Index::new(3)), "[========> ] 2/3"); assert_eq!(progress_bar(Index::new(3), Index::new(3)), "[================> ] 3/3"); // Progress strings with 1 entry: assert_eq!(progress_bar(Index::new(1), Index::new(1)), "[> ] 1/1"); } } hurl-7.1.0/src/parallel/runner.rs000064400000000000000000000360351046102023000150540ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::sync::mpsc::{Receiver, Sender}; use std::sync::{mpsc, Arc, Mutex}; use super::error::JobError; use super::job::{Job, JobQueue, JobResult}; use super::message::WorkerMessage; use super::progress::{Mode, ParProgress}; use super::worker::{Worker, WorkerId}; use crate::output; use crate::pretty::PrettyMode; use crate::util::term::{Stderr, Stdout, WriteMode}; use hurl_core::error::{DisplaySourceError, OutputFormat}; use hurl_core::types::{Count, Index}; /// A parallel runner manages a list of `Worker`. Each worker is either idle or is running a /// [`Job`]. To run jobs, the [`ParallelRunner::run`] method much be executed on the main thread. /// Each worker has its own thread that it uses to run a Hurl file, and communicates with the main /// thread. Standard multi-producer single-producer channels are used between the main runner and /// the workers to send job request and receive job result. /// /// The parallel runner is responsible to manage the state of the workers, and to display standard /// output and standard error, in the main thread. Each worker reports its progression to the /// parallel runner, which updates the workers states and displays a progress bar. /// Inside each worker, logs (messages on standard error) and HTTP response (output on /// standard output) are buffered and send to the runner to be eventually displayed. /// /// By design, the workers state is read and modified on the main thread. pub struct ParallelRunner { /// The list of workers, running Hurl file in their inner thread. workers: Vec<(Worker, WorkerState)>, /// The transmit end of the channel used to send messages to workers. tx: Option>, /// The receiving end of the channel used to communicate to workers. rx: Receiver, /// Progress reporter to display the advancement of the parallel runs. progress: ParProgress, /// Output type for each completed job on standard output. output_type: OutputType, /// Repeat mode for the runner: infinite or finite. repeat: Count, } /// Represents a worker's state. #[allow(clippy::large_enum_variant)] pub enum WorkerState { /// Worker has no job to run. Idle, /// Worker is currently running a `job`, the entry index being executed is `current_entry`, /// the last entry index to be run is `last_entry` and `retry_count` is the number of retries. Running { job: Job, current_entry: Index, last_entry: Index, retry_count: usize, }, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum OutputType { /// The last HTTP response body of a Hurl file is outputted on standard output. ResponseBody { include_headers: bool, color: bool, pretty: PrettyMode, }, /// The whole Hurl file run is exported in a structured JSON export on standard output. Json, /// Nothing is outputted on standard output when a Hurl file run is completed. NoOutput, } const MAX_RUNNING_DISPLAYED: usize = 8; impl ParallelRunner { /// Creates a new parallel runner, with `worker_count` worker thread. /// /// The runner runs a list of [`Job`] in parallel. It creates two channels to communicate /// with the workers: /// /// - `runner -> worker`: is used to send [`Job`] processing request to a worker, /// - `worker -> runner`: is used to send [`WorkerMessage`] to update job progression, from a /// worker to the runner. /// /// When a job is completed, depending on `output_type`, it can be outputted to standard output: /// whether as a raw response body bytes, or in a structured JSON output. /// /// The runner can repeat running a list of jobs. For instance, when repeating two times the job /// sequence (`a`, `b`, `c`), runner will act as if it runs (`a`, `b`, `c`, `a`, `b`, `c`). /// /// If `test` mode is `true` the runner is run in "test" mode, reporting the success or failure /// of each file on standard error. In addition to the test mode, a `progress_bar` designed for /// parallel run progression can be used. When the progress bar is displayed, it's wrapped with /// new lines at width `max_width`. /// /// `color` determines if color if used in standard error. pub fn new( workers_count: usize, output_type: OutputType, repeat: Count, test: bool, progress_bar: bool, color: bool, max_width: Option, ) -> Self { // Worker are running on theirs own thread, while parallel runner is running in the main // thread. // We create the channel to communicate from workers to the parallel runner. let (tx_in, rx_in) = mpsc::channel(); // We create the channel to communicate from the parallel runner to the workers. let (tx_out, rx_out) = mpsc::channel(); let rx_out = Arc::new(Mutex::new(rx_out)); // Create the workers: let workers = (0..workers_count) .map(|i| { let worker = Worker::new(WorkerId::from(i), &tx_in, &rx_out); let state = WorkerState::Idle; (worker, state) }) .collect::>(); let mode = Mode::new(test, progress_bar); let progress = ParProgress::new(MAX_RUNNING_DISPLAYED, mode, color, max_width); ParallelRunner { workers, tx: Some(tx_out), rx: rx_in, progress, output_type, repeat, } } /// Runs a list of [`Job`] in parallel and returns the results. /// /// Results are returned ordered by the sequence number, and not their execution order. So, the /// order of the `jobs` is the same as the order of the `jobs` results, independently of the /// worker's count. pub fn run(&mut self, jobs: &[Job]) -> Result, JobError> { // The parallel runner runs on the main thread. It's responsible for displaying standard // output and standard error. Workers are buffering their output and error in memory, and // delegate the display to the runners. let mut stdout = Stdout::new(WriteMode::Immediate); let mut stderr = Stderr::new(WriteMode::Immediate); // Create the jobs queue: let mut queue = JobQueue::new(jobs, self.repeat); let jobs_count = queue.jobs_count(); // Initiate the runner, fill our workers: self.workers.iter().for_each(|_| { if let Some(job) = queue.next() { _ = self.tx.as_ref().unwrap().send(job); } }); // When dumped HTTP responses, we truncate existing output file on first save, then append // it on subsequent write. let mut append = false; // Start the message pump: let mut results = vec![]; for msg in self.rx.iter() { match msg { // If we have any error (either a [`WorkerMessage::IOError`] or a [`WorkerMessage::ParsingError`] // we don't take any more jobs and exit from the methods in error. This is the same // behaviour as when we run sequentially a list of Hurl files. WorkerMessage::InputReadError(msg) => { self.progress.clear_progress_bar(&mut stderr); let filename = msg.job.filename; let error = msg.error; let message = format!("Issue reading from {filename}: {error}"); return Err(JobError::InputRead(message)); } WorkerMessage::ParsingError(msg) => { // Like [`hurl::runner::run`] method, the display of parsing error is done here // instead of being done in [`hurl::run_par`] method. self.progress.clear_progress_bar(&mut stderr); stderr.eprint(msg.stderr.buffer()); return Err(JobError::Parsing); } // Everything is OK, we report the progress. As we can receive a lot of running // messages, we don't want to update the progress bar too often to avoid flickering. WorkerMessage::Running(msg) => { self.workers[msg.worker_id.0].1 = WorkerState::Running { job: msg.job, current_entry: msg.current_entry, last_entry: msg.last_entry, retry_count: msg.retry_count, }; if self.progress.can_update() { self.progress.clear_progress_bar(&mut stderr); self.progress.update_progress_bar( &self.workers, results.len(), jobs_count, &mut stderr, ); } } // A new job has been completed, we take a new job if the queue is not empty. // Contrary to when we receive a running message, we clear the progress bar no // matter what the frequency is, to get a "correct" and up-to-date display on any // test completion. WorkerMessage::Completed(msg) => { self.progress.clear_progress_bar(&mut stderr); // The worker is becoming idle. self.workers[msg.worker_id.0].1 = WorkerState::Idle; // First, we display the job standard error, then the job standard output // (similar to the sequential runner). if !msg.stderr.buffer().is_empty() { stderr.eprint(msg.stderr.buffer()); } if !msg.stdout.buffer().is_empty() { let ret = stdout.write_all(msg.stdout.buffer()); if ret.is_err() { return Err(JobError::OutputWrite( "Issue writing to stdout".to_string(), )); } } // Then, we print job output on standard output (the first response truncates // exiting file, subsequent response appends bytes). self.print_output(&msg.result, &mut stdout, append)?; append = true; // Report the completion of this job and update the progress. self.progress.print_completed(&msg.result, &mut stderr); results.push(msg.result); self.progress.update_progress_bar( &self.workers, results.len(), jobs_count, &mut stderr, ); // We want to force the next refresh of the progress bar (when we receive a // running message) to be sure that the new next jobs will be printed. This // is needed because we've a throttle on the progress bar refresh and not every // running messages received leads to a progress bar refresh. self.progress.force_next_update(); // We run the next job to process: let job = queue.next(); match job { Some(job) => { _ = self.tx.as_ref().unwrap().send(job); } None => { // If we have received all the job results, we can stop the run. if let Count::Finite(jobs_count) = jobs_count { if results.len() == jobs_count { break; } } } } } } } // We gracefully shut down workers, by dropping the sender and wait for each thread workers // to join. drop(self.tx.take()); for worker in &mut self.workers { if let Some(thread) = worker.0.take_thread() { thread.join().unwrap(); } } // All jobs have been executed, we sort results by sequence number to get the same order // as the input jobs list. results.sort_unstable_by_key(|result| result.job.seq); Ok(results) } /// Prints a job `result` to standard output `stdout`, either as a raw HTTP response (last /// body of the run), or in a structured JSON way. /// If `append` is true, any existing file will be appended instead of being truncated. fn print_output( &self, result: &JobResult, stdout: &mut Stdout, append: bool, ) -> Result<(), JobError> { let job = &result.job; let content = &result.content; let hurl_result = &result.hurl_result; let filename_in = &job.filename; let filename_out = job.runner_options.output.as_ref(); match self.output_type { OutputType::ResponseBody { include_headers, color, pretty, } => { if hurl_result.success { let result = output::write_last_body( hurl_result, include_headers, color, pretty, filename_out, stdout, append, ); if let Err(e) = result { let message = e.render( &filename_in.to_string(), content, None, OutputFormat::Terminal(color), ); return Err(JobError::OutputWrite(message)); } } } OutputType::Json => { let result = output::write_json( hurl_result, content, filename_in, filename_out, stdout, append, ); if let Err(error) = result { return Err(JobError::OutputWrite(error.to_string())); } } OutputType::NoOutput => {} } Ok(()) } } hurl-7.1.0/src/parallel/worker.rs000064400000000000000000000136461046102023000150570ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::{fmt, thread}; use super::job::{Job, JobResult}; use super::message::{CompletedMsg, InputReadErrorMsg, ParsingErrorMsg, RunningMsg, WorkerMessage}; use crate::runner; use crate::runner::EventListener; use crate::util::logger::Logger; use crate::util::term::{Stderr, Stdout, WriteMode}; use hurl_core::error::{DisplaySourceError, OutputFormat}; use hurl_core::parser; use hurl_core::types::Index; /// A worker runs job in its own thread. pub struct Worker { /// The id of this worker. worker_id: WorkerId, /// The thread handle of this worker. thread: Option>, } impl fmt::Display for Worker { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "id: {}", self.worker_id) } } /// Identifier of a worker. #[derive(Copy, Clone, Debug)] pub struct WorkerId(pub usize); impl From for WorkerId { fn from(value: usize) -> Self { WorkerId(value) } } impl fmt::Display for WorkerId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) } } impl Worker { /// Creates a new worker, with id `worker_id`. /// /// The worker spawns a new thread and process [`Job`] sent by the parallel runner through `rx` /// (the receiving part of the `runner -> worker` channel). Worker send message back to the /// runner to update the job progression thorough `tx` (the sending part of the `worker -> runner`. pub fn new( worker_id: WorkerId, tx: &Sender, rx: &Arc>>, ) -> Self { let rx = Arc::clone(rx); let tx = tx.clone(); let thread = thread::spawn(move || loop { let Ok(job) = rx.lock().unwrap().recv() else { return; }; // In parallel execution, standard output and standard error messages are buffered // (in sequential mode, we'll use immediate standard output and error). let mut stdout = Stdout::new(WriteMode::Buffered); let stderr = Stderr::new(WriteMode::Buffered); // We also create a common logger for this run (logger verbosity can eventually be // mutated on each entry). let secrets = job.variables.secrets(); let mut logger = Logger::new(&job.logger_options, stderr, &secrets); // Create a worker progress listener. let progress = WorkerProgress::new(worker_id, &job, &tx); let content = job.filename.read_to_string(); let content = match content { Ok(c) => c, Err(e) => { let msg = InputReadErrorMsg::new(worker_id, &job, e); _ = tx.send(WorkerMessage::InputReadError(msg)); return; } }; // Try to parse the content let hurl_file = parser::parse_hurl_file(&content); let hurl_file = match hurl_file { Ok(h) => h, Err(error) => { let filename = job.filename.to_string(); let message = error.render( &filename, &content, None, OutputFormat::Terminal(logger.color), ); logger.error_rich(&message); let msg = ParsingErrorMsg::new(worker_id, &job, &logger.stderr); _ = tx.send(WorkerMessage::ParsingError(msg)); return; } }; // Now, we have a syntactically correct HurlFile instance, we can run it. let result = runner::run_entries( &hurl_file.entries, &content, Some(&job.filename), &job.runner_options, &job.variables, &mut stdout, Some(&progress), &mut logger, ); if result.success && result.entries.last().is_none() { logger.warning(&format!( "No entry have been executed for file {}", job.filename )); } let job_result = JobResult::new(job, content, result); let msg = CompletedMsg::new(worker_id, job_result, stdout, logger.stderr); _ = tx.send(WorkerMessage::Completed(msg)); }); Worker { worker_id, thread: Some(thread), } } /// Takes the thread out of the worker, leaving a None in its place. pub fn take_thread(&mut self) -> Option> { self.thread.take() } } struct WorkerProgress { worker_id: WorkerId, job: Job, tx: Sender, } impl WorkerProgress { fn new(worker_id: WorkerId, job: &Job, tx: &Sender) -> Self { WorkerProgress { worker_id, job: job.clone(), tx: tx.clone(), } } } impl EventListener for WorkerProgress { fn on_entry_running(&self, current: Index, last: Index, retry_count: usize) { let msg = RunningMsg::new(self.worker_id, &self.job, current, last, retry_count); _ = self.tx.send(WorkerMessage::Running(msg)); } } hurl-7.1.0/src/pretty/json.rs000064400000000000000000001556371046102023000142610ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::cmp::PartialEq; use std::{fmt, io}; /// A fast zero dependency JSON formatter / pretty printer. /// This is a fast JSON formatter (up to x2 compared to pretty printing with [Serde JSON](https://github.com/serde-rs/json)). /// This formatter parses and formats JSON input byte by byte and do not require pre UTF-8 validation. /// UTF-8 validation is done in-place, on the fly, while parsing strings. This implementation try to not allocate /// anything. It does not try to normalise, remove unnecessary escaping, it just formats the actual input /// with spaces, newlines and (optionally) color. /// /// This formatter supports writing to a [`io::Write`] instance (file, standard output), or to a [`fmt::Write`] /// buffer (string etc...). If the formatting fails, the write buffer may contain some unwanted data /// from the already read bytes. It's up to the caller to deal with this kind of failure (clear a buffer /// for instance etc...). pub struct Formatter<'input> { /// The JSON input bytes to prettify. input: &'input [u8], /// Cursor position in byte offset (starting at 0) pos: BytePos, /// Current indentation level (this is maxed by [`MAX_INDENT_LEVEL`]) level: usize, /// Use color with ANSI escape code when prettifying. color: Color, } /// The maximum indentation level supported before errors. const MAX_INDENT_LEVEL: usize = 100; /// A byte position in a bytes stream (0-based index). #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub struct BytePos(usize); /// Potential errors raised during formatting. #[derive(Debug, Clone, Eq, PartialEq)] pub enum FormatError { /// Unexpected end of file. Eof, /// Invalid byte at this position. InvalidByte(u8, BytePos), /// The next bytes are not a valid UTF-8 sequence. InvalidUtf8([u8; 4], usize, BytePos), /// Invalid escaped byte at this position. InvalidEscape(u8, BytePos), /// The maximum indent level has been reached. MaxIndentLevel(usize, BytePos), /// Write error occuring when formatting to an [`io::Write`] (file, standard output etc...) Io(io::ErrorKind), /// Write error occuring when formatting to an [`fmt::Write`] (string, etc...) Fmt(fmt::Error), } impl fmt::Display for FormatError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn debug_str_u8(b: u8) -> String { match char::from(b) { c if c.is_ascii_graphic() || c == ' ' => { format!("0x{b:02x?}/'{c}'") } _ => format!("0x{b:02x?}"), } } match self { FormatError::Eof => write!(f, "unexpected end of file"), FormatError::InvalidByte(byte, pos) => { let byte = debug_str_u8(*byte); write!(f, "invalid byte {byte} at offset {}", pos.0) } FormatError::InvalidUtf8(bytes, len, pos) => { let hex = bytes .iter() .take(*len) .map(|b| format!("0x{:02x}", b)) .collect::>() .join(" "); write!(f, "invalid UTF-8 {} bytes {hex} at offset {}", len, pos.0) } FormatError::InvalidEscape(byte, pos) => { let byte = debug_str_u8(*byte); write!(f, "invalid escaped byte {byte} at offset {}", pos.0) } FormatError::MaxIndentLevel(level, pos) => { write!(f, "maximum indent level {} at offset {}", level, pos.0) } FormatError::Io(error) => write!(f, "error writing {error}"), FormatError::Fmt(error) => write!(f, "error writing {error}"), } } } impl From for FormatError { fn from(e: io::Error) -> Self { FormatError::Io(e.kind()) } } impl From for FormatError { fn from(e: fmt::Error) -> Self { FormatError::Fmt(e) } } /// Whether we prettify JSON with [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code) /// or not. #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub enum Color { NoColor, Ansi, } /// Is the current string token being processed semantically a "key" or a "value". The color /// used for prettifying depends on it. #[derive(Debug, Copy, Clone, Eq, PartialEq)] enum StringMode { Key, Value, } type FormatResult = Result; /// Helpers method to prettify to an [`io::Write`] (File, standard output etc...) pub fn format(input: &[u8], color: Color, out: &mut impl io::Write) -> FormatResult<()> { let mut formatter = Formatter::new(input, color); formatter.format(out) } impl<'input> Formatter<'input> { /// Creates a new formater, with JSON `input` bytes to format and colorize. pub fn new(input: &'input [u8], color: Color) -> Self { Formatter { input, pos: BytePos(0), level: 0, color, } } /// Reads the next byte and advances the read position. #[inline] fn next_byte(&mut self) -> Option { let b = self.peek_byte()?; self.pos.0 += 1; Some(b) } /// Peeks the next byte without advancing the read position. #[inline] fn peek_byte(&mut self) -> Option { self.input.get(self.pos.0).copied() } /// Reads the next byte, advances the read position and check the read value. #[inline] fn expect_byte(&mut self, expected: u8) -> FormatResult<()> { match self.next_byte() { Some(b) if b == expected => Ok(()), Some(b) => Err(FormatError::InvalidByte(b, BytePos(self.pos.0 - 1))), None => Err(FormatError::Eof), } } /// Increments the indentation level. fn inc_level(&mut self) -> FormatResult<()> { if self.level >= MAX_INDENT_LEVEL { return Err(FormatError::MaxIndentLevel(self.level, self.pos)); } self.level += 1; Ok(()) } /// Decrements the indentation level. fn dec_level(&mut self) { self.level -= 1; } /// Formats and colorize the JSON input bytes. pub fn format(&mut self, out: &mut impl io::Write) -> FormatResult<()> { self.skip_start_bom(); self.skip_whitespace(); self.parse_value(out)?; self.skip_whitespace(); // End the prettified output with a trailing newline. self.write_ln(out)?; // Have we completely consumed our payload? if let Some(b) = self.peek_byte() { Err(FormatError::InvalidByte(b, self.pos)) } else { Ok(()) } } /// Skips BOM (Byte Order Mark) at the start of the read buffer. fn skip_start_bom(&mut self) { debug_assert!(self.pos.0 == 0); if self.input.len() < 3 { return; } if self.input[0] == 0xEF && self.input[1] == 0xBB && self.input[2] == 0xBF { self.pos.0 = 3; } } fn skip_whitespace(&mut self) { while matches!(self.peek_byte(), Some(b' ' | b'\n' | b'\r' | b'\t')) { self.pos.0 += 1; } } /// Processes a JSON value. fn parse_value(&mut self, out: &mut impl io::Write) -> FormatResult<()> { // From : // // value = false / null / true / object / array / number / string // false = %x66.61.6c.73.65 ; false // null = %x6e.75.6c.6c ; null // true = %x74.72.75.65 ; true match self.peek_byte() { Some(b'"') => self.parse_string(out, StringMode::Value), Some(b'-' | b'0'..=b'9') => self.parse_number(out), Some(b'{') => self.parse_object(out), Some(b'[') => self.parse_array(out), Some(b't') => self.parse_true(out), Some(b'f') => self.parse_false(out), Some(b'n') => self.parse_null(out), Some(b) => Err(FormatError::InvalidByte(b, self.pos)), None => Err(FormatError::Eof), } } /// Processes a JSON object. fn parse_object(&mut self, out: &mut impl io::Write) -> FormatResult<()> { // From : // object = begin-object [ member *( value-separator member ) ] // end-object // member = string name-separator value self.expect_byte(b'{')?; // For empty objects, we keep a short compact form: self.skip_whitespace(); if self.peek_byte() == Some(b'}') { self.next_byte(); self.write_empty_obj(out)?; return Ok(()); } // Now, we have a non-empty object. self.write_begin_obj(out)?; self.inc_level()?; let mut first = true; loop { self.skip_whitespace(); if self.peek_byte() == Some(b'}') { self.next_byte(); self.dec_level(); self.write_ln(out)?; self.write_indent(out)?; self.write_end_obj(out)?; return Ok(()); } if first { first = false; } else { self.expect_byte(b',')?; self.skip_whitespace(); self.write_value_sep(out)?; } // Parse key self.write_indent(out)?; self.parse_string(out, StringMode::Key)?; // Parse colon self.skip_whitespace(); self.expect_byte(b':')?; self.write_name_sep(out)?; // Parse value self.skip_whitespace(); self.parse_value(out)?; } } /// Processes a JSON array. fn parse_array(&mut self, out: &mut impl io::Write) -> FormatResult<()> { // From : // array = begin-array [ value *( value-separator value ) ] end-array self.expect_byte(b'[')?; // For empty arrays, we keep a short compact form: self.skip_whitespace(); if self.peek_byte() == Some(b']') { self.next_byte(); self.write_empty_arr(out)?; return Ok(()); } // Now, we have a non-empty array. self.write_begin_arr(out)?; self.inc_level()?; let mut first = true; loop { self.skip_whitespace(); if self.peek_byte() == Some(b']') { self.next_byte(); self.dec_level(); self.write_ln(out)?; self.write_indent(out)?; self.write_end_arr(out)?; return Ok(()); } if first { first = false; } else { self.expect_byte(b',')?; self.skip_whitespace(); self.write_value_sep(out)?; } self.write_indent(out)?; self.parse_value(out)?; } } /// Processes a JSON string. The string is not normalized, escapes are preserved and the string /// bytes are validated on-the-fly to be UTF-8 valid. fn parse_string(&mut self, out: &mut impl io::Write, mode: StringMode) -> FormatResult<()> { // From let start = self.pos; self.expect_byte(b'"')?; while let Some(b) = self.peek_byte() { match b { b'"' => { self.next_byte(); // Flush plain segment before exit. let string = &self.input[start.0..self.pos.0]; match mode { StringMode::Key => self.write_key(string, out)?, StringMode::Value => self.write_value(string, out)?, }; return Ok(()); } // Escaping b'\\' => { self.next_byte(); match self.next_byte() { Some(b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't') => {} Some(b'u') => { for _ in 0..4 { let hex = self.next_byte().ok_or(FormatError::Eof)?; if !(hex as char).is_ascii_hexdigit() { return Err(FormatError::InvalidByte( hex, BytePos(self.pos.0 - 1), )); } } } Some(b) => return Err(FormatError::InvalidEscape(b, self.pos)), None => return Err(FormatError::Eof), } } 0x00..=0x1F => return Err(FormatError::InvalidByte(b, self.pos)), _ => { // Decode valid UTF-8 char self.next_utf8_char()?; } } } Err(FormatError::Eof) } /// Processes a `true` literal. fn parse_true(&mut self, out: &mut impl io::Write) -> FormatResult<()> { for &b in b"true" { self.expect_byte(b)?; } self.write_true(out)?; Ok(()) } /// Processes a `false` literal. fn parse_false(&mut self, out: &mut impl io::Write) -> FormatResult<()> { for &b in b"false" { self.expect_byte(b)?; } self.write_false(out)?; Ok(()) } /// Processes a `null` literal. fn parse_null(&mut self, out: &mut impl io::Write) -> FormatResult<()> { for &b in b"null" { self.expect_byte(b)?; } self.write_null(out)?; Ok(()) } /// Processes a JSON number. fn parse_number(&mut self, out: &mut impl io::Write) -> FormatResult<()> { // From the spec : // // number = [ minus ] int [ frac ] [ exp ] // decimal-point = %x2E ; . // digit1-9 = %x31-39 ; 1-9 // e = %x65 / %x45 ; e E // exp = e [ minus / plus ] 1*DIGIT // frac = decimal-point 1*DIGIT // int = zero / ( digit1-9 *DIGIT ) // minus = %x2D ; - // plus = %x2B ; + // zero = %x30 ; 0 let start = self.pos; if self.peek_byte() == Some(b'-') { self.next_byte(); } self.parse_integer()?; self.parse_fraction()?; self.parse_exponent()?; // Finally, write numbers let digits = &self.input[start.0..self.pos.0]; self.write_number(digits, out)?; Ok(()) } /// Processes the integer part of a number. fn parse_integer(&mut self) -> FormatResult<()> { match self.peek_byte() { Some(b'0') => { self.next_byte(); Ok(()) } Some(b'1'..=b'9') => { self.next_byte(); // 0 or more digits while let Some(b'0'..=b'9') = self.peek_byte() { self.next_byte(); } Ok(()) } Some(b) => Err(FormatError::InvalidByte(b, self.pos)), None => Err(FormatError::Eof), } } /// Processes the fractional part of a number. fn parse_fraction(&mut self) -> FormatResult<()> { if self.peek_byte() == Some(b'.') { self.next_byte(); // 1 or more digits match self.peek_byte() { Some(b'0'..=b'9') => { self.next_byte(); while let Some(b'0'..=b'9') = self.peek_byte() { self.next_byte(); } Ok(()) } Some(b) => Err(FormatError::InvalidByte(b, self.pos)), None => Err(FormatError::Eof), }?; } Ok(()) } /// Processes the exponent part of a number. fn parse_exponent(&mut self) -> FormatResult<()> { match self.peek_byte() { Some(b'e' | b'E') => { self.next_byte(); if let Some(b'+' | b'-') = self.peek_byte() { self.next_byte(); } match self.peek_byte() { Some(b'0'..=b'9') => { self.next_byte(); while let Some(b'0'..=b'9') = self.peek_byte() { self.next_byte(); } Ok(()) } Some(b) => Err(FormatError::InvalidByte(b, self.pos)), None => Err(FormatError::Eof), } } _ => Ok(()), } } /// Read and advances to the next UTF-8 char (may advance 1 to 4 bytes). /// The code check for UTF-8 validity, reference is from /// Bytes bounds values and logic are extracted from this [table](https://en.wikipedia.org/wiki/UTF-8#Byte_map): /// /// | | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F | /// |---|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|-----| /// | 0 | NUL | SOH | STX | ETX | EOT | ENQ | ACK | BEL | BS | HT | LF | VT | FF | CR | SO | SI | /// | 1 | DLE | DC1 | DC2 | DC3 | DC4 | NAK | SYN | ETB | CAN | EM | SUB | ESC | FS | GS | RS | US | /// | 2 | SP | ! | " | # | $ | % | & | ' | ( | ) | * | + | , | - | . | / | /// | 3 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | : | ; | < | = | > | ? | /// | 4 | @ | A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | /// | 5 | P | Q | R | S | T | U | V | W | X | Y | Z | [ | \ | ] | ^ | _ | /// | 6 | ` | a | b | c | d | e | f | g | h | i | j | k | l | m | n | o | /// | 7 | p | q | r | s | t | u | v | w | x | y | z | { | \| | } | ~ | DEL | /// | 8 | con | con | con | con | con | con | con | con | con | con | con | con | con | con | con | con | /// | 9 | con | con | con | con | con | con | con | con | con | con | con | con | con | con | con | con | /// | A | con | con | con | con | con | con | con | con | con | con | con | con | con | con | con | con | /// | B | con | con | con | con | con | con | con | con | con | con | con | con | con | con | con | con | /// | C | ▒ | ▒ | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | /// | D | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | 2 | /// | E | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | 3 | /// | F | 4 | 4 | 4 | 4 | 4 | ▒ | ▒ | ▒ | ▒ | ▒ | ▒ | ▒ | ▒ | ▒ | ▒ | ▒ | /// /// - con => Continuation byte /// - 2 => First byte of 2-byte sequence /// - 3 => First byte of 3-byte sequence /// - 4 => First byte of 4-byte sequence /// - ▒ => not used fn next_utf8_char(&mut self) -> FormatResult<()> { #[inline(always)] fn cont(b: u8) -> bool { (b & 0xC0) == 0x80 } let start_pos = self.pos; // Case 1: Single-byte ASCII character (0xxxxxxx). let b1 = self.next_byte().ok_or(FormatError::Eof)?; if b1 < 0x80 { return Ok(()); } // Case 2: Two-byte sequence (110xxxxx 10xxxxxx). let b2 = self.next_byte().ok_or(FormatError::Eof)?; if b1 < 0xE0 { return if (0xC2..=0xDF).contains(&b1) && cont(b2) { Ok(()) } else { Err(FormatError::InvalidUtf8([b1, b2, 0, 0], 2, start_pos)) }; } // Case 3: Three-byte sequence (1110xxxx 10xxxxxx 10xxxxxx). let b3 = self.next_byte().ok_or(FormatError::Eof)?; if b1 < 0xF0 { return if match b1 { // See // Overlong encodings: // > An overlong encoding (0xE0 followed by less than 0xA0, ...) 0xE0 => (0xA0..=0xBF).contains(&b2) && cont(b3), // Can't be UTF-16 surrogates: // > A 3-byte sequence that decodes to a UTF-16 surrogate U+0xD800–0xDFFF (0xED followed by 0xA0 or greater) 0xED => (0x80..=0x9F).contains(&b2) && cont(b3), // General case: 2 continuation bytes 0xE1..=0xEC | 0xEE..=0xEF => cont(b2) && cont(b3), _ => false, } { Ok(()) } else { Err(FormatError::InvalidUtf8([b1, b2, b3, 0], 3, self.pos)) }; } // Case 4: Four-byte sequence (11110xxx 10xxxxxx 10xxxxxx 10xxxxxx). let b4 = self.next_byte().ok_or(FormatError::Eof)?; if match b1 { // See // Overlong encodings: // > An overlong encoding (..., or 0xF0 followed by less than 0x90) 0xF0 => (0x90..=0xBF).contains(&b2) && cont(b3) && cont(b4), // Limit to code point 0x10FFFF // > A 4-byte sequence that decodes to a value greater than U+10FFFF (0xF4 followed by 0x90 or greater) 0xF4 => (0x80..=0x8F).contains(&b2) && cont(b3) && cont(b4), // General case: 3 continuation bytes 0xF1..=0xF3 => cont(b2) && cont(b3) && cont(b4), _ => false, } { Ok(()) } else { Err(FormatError::InvalidUtf8([b1, b2, b3, b4], 4, self.pos)) } } } const SPACES: &[u8] = b" "; /// Methods to print on a [Write], with color, or not. impl<'input> Formatter<'input> { fn write_indent(&self, out: &mut impl io::Write) -> Result<(), io::Error> { let n = self.level * 2; let full_chunks = n / SPACES.len(); let remainder = n % SPACES.len(); for _ in 0..full_chunks { out.write_all(SPACES)?; } out.write_all(&SPACES[..remainder])?; Ok(()) } #[inline] fn write_ln(&self, out: &mut impl io::Write) -> Result<(), io::Error> { out.write_all(b"\n") } #[inline] fn write_empty_obj(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[1;39m{}\x1b[0m") } else { out.write_all(b"{}") } } #[inline] fn write_begin_obj(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[1;39m{\x1b[0m\n") } else { out.write_all(b"{\n") } } #[inline] fn write_end_obj(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[1;39m}\x1b[0m") } else { out.write_all(b"}") } } #[inline] fn write_value_sep(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[1;39m,\x1b[0m\n") } else { out.write_all(b",\n") } } #[inline] fn write_name_sep(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[1;39m:\x1b[0m ") } else { out.write_all(b": ") } } #[inline] fn write_empty_arr(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[1;39m[]\x1b[0m") } else { out.write_all(b"[]") } } #[inline] fn write_begin_arr(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[1;39m[\x1b[0m\n") } else { out.write_all(b"[\n") } } #[inline] fn write_end_arr(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[1;39m]\x1b[0m") } else { out.write_all(b"]") } } #[inline] fn write_key(&self, s: &[u8], out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[1;34m")?; out.write_all(s)?; out.write_all(b"\x1b[0m") } else { out.write_all(s) } } #[inline] fn write_value(&self, s: &[u8], out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[0;32m")?; out.write_all(s)?; out.write_all(b"\x1b[0m") } else { out.write_all(s) } } #[inline] fn write_true(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[0;33mtrue\x1b[0m") } else { out.write_all(b"true") } } #[inline] fn write_false(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[0;33mfalse\x1b[0m") } else { out.write_all(b"false") } } #[inline] fn write_null(&self, out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[0;35mnull\x1b[0m") } else { out.write_all(b"null") } } #[inline] fn write_number(&self, s: &[u8], out: &mut impl io::Write) -> Result<(), io::Error> { if self.color == Color::Ansi { out.write_all(b"\x1b[0;36m")?; out.write_all(s)?; out.write_all(b"\x1b[0m") } else { out.write_all(s) } } } #[cfg(test)] mod tests { use super::*; /// Helpers method to prettify to an [`fmt::Write`] (String etc...) fn format_fmt(input: &[u8], color: Color, out: &mut impl fmt::Write) -> FormatResult<()> { let mut formatter = Formatter::new(input, color); let mut bytes = Vec::new(); formatter.format(&mut bytes)?; // If format is successful, we are sure that the bytes are UTF-8 valid. let str = std::str::from_utf8(&bytes).unwrap(); out.write_str(str)?; Ok(()) } #[test] fn parse_number_ok() { let datas = [ // Parse some integers ("1234xxxx", "1234"), ("42", "42"), ( "1233456787766677889778998789988", "1233456787766677889778998789988", ), ("0000", "0"), ("0", "0"), ("-0", "-0"), ("012345", "0"), ("0abcdef", "0"), ("-10256", "-10256"), ("-012344", "-0"), // Parse real (with fraction) ("1.000", "1.000"), ("1.7b", "1.7"), ]; for (input, expected) in datas { let mut formatter = Formatter::new(input.as_bytes(), Color::NoColor); let mut out = Vec::new(); formatter.parse_number(&mut out).unwrap(); assert_eq!(String::from_utf8(out).unwrap(), expected); } } #[test] fn parse_number_failed() { let datas = ["1.", "78980.a", "abc"]; for input in datas { let mut formatter = Formatter::new(input.as_bytes(), Color::NoColor); let mut out = Vec::new(); let result = formatter.parse_number(&mut out); assert!(result.is_err()); } } fn assert_against_std(bytes: &[u8], len: usize) { // We pass the full buffer to the parser, with some trailing bytes let mut formatter = Formatter::new(bytes, Color::NoColor); let ret = formatter.next_utf8_char(); // We test against a buffer without trailing match std::str::from_utf8(&bytes[..len]) { Ok(str) => { assert!(ret.is_ok()); assert_eq!(formatter.pos.0, len); let out = str::from_utf8(&formatter.input[0..formatter.pos.0]).unwrap(); assert_eq!(out, str); } Err(_) => { assert!(ret.is_err()); } } } #[test] fn try_read_one_byte_to_utf8() { // Iterate through all 1-byte UTF-8 bytes, even invalid for b in 0x00..=0xFF { let bytes = [b, b'x', b'x', b'x']; assert_against_std(&bytes, 1); } } #[test] fn try_read_two_bytes_to_utf8() { // Iterate through all UTF-8 2-bytes: C0..=DF 80..=BF // It may contains invalid ones (overlong for instance). for b1 in 0xC0..=0xDF { for b2 in 0x80..=0xBF { let bytes = [b1, b2, b'x', b'x', b'x']; assert_against_std(&bytes, 2); } } } #[test] fn try_read_three_bytes_to_utf8() { // Iterate through all UTF-8 3-bytes: E0..=EF 80..=BF 80..=BF // It may contains invalid ones (overlong for instance). for b1 in 0xF0..=0xF7 { for b2 in 0x80..=0xBF { for b3 in 0x80..=0xBF { let bytes = [b1, b2, b3, b'x', b'x', b'x']; assert_against_std(&bytes, 3); } } } } #[test] fn try_read_four_bytes_to_utf8() { // Iterate through all UTF-8 4-bytes: F0..=F7 80..=BF 80..=BF 80..=BF // It may contains invalid ones (overlong for instance). for b1 in 0xF0..=0xF7 { for b2 in 0x80..=0xBF { for b3 in 0x80..=0xBF { for b4 in 0x80..=0xBF { let bytes = [b1, b2, b3, b4, b'x', b'x', b'x']; assert_against_std(&bytes, 4); } } } } } #[test] fn format_valid_json() { struct TestData { input: &'static str, expected: &'static str, } let datas = [ TestData { input: r#"{"strings":{"english":"Hello, world!","chinese":"你好,世界","japanese":"こんにちは世界","korean":"안녕하세요 세계","arabic":"مرحبا بالعالم","hindi":"नमस्ते दुनिया","russian":"Привет, мир","greek":"Γειά σου Κόσμε","hebrew":"שלום עולם","accented":"Curaçao, naïve, façade, jalapeño"},"numbers":{"zero":0,"positive_int":42,"negative_int":-42,"large_int":1234567890123456789,"small_float":0.000123,"negative_float":-3.14159,"large_float":1.7976931348623157e308,"smallest_float":5e-324,"sci_notation_positive":6.022e23,"sci_notation_negative":-2.99792458e8},"booleans":{"isActive":true,"isDeleted":false},"emojis":{"happy":"😀","sad":"😢","fire":"🔥","rocket":"🚀","earth":"🌍","heart":"❤️","multi":"👩‍💻🧑🏽‍🚀👨‍👩‍👧‍👦"},"nothing":null}"#, expected: r#"{ "strings": { "english": "Hello, world!", "chinese": "你好,世界", "japanese": "こんにちは世界", "korean": "안녕하세요 세계", "arabic": "مرحبا بالعالم", "hindi": "नमस्ते दुनिया", "russian": "Привет, мир", "greek": "Γειά σου Κόσμε", "hebrew": "שלום עולם", "accented": "Curaçao, naïve, façade, jalapeño" }, "numbers": { "zero": 0, "positive_int": 42, "negative_int": -42, "large_int": 1234567890123456789, "small_float": 0.000123, "negative_float": -3.14159, "large_float": 1.7976931348623157e308, "smallest_float": 5e-324, "sci_notation_positive": 6.022e23, "sci_notation_negative": -2.99792458e8 }, "booleans": { "isActive": true, "isDeleted": false }, "emojis": { "happy": "😀", "sad": "😢", "fire": "🔥", "rocket": "🚀", "earth": "🌍", "heart": "❤️", "multi": "👩‍💻🧑🏽‍🚀👨‍👩‍👧‍👦" }, "nothing": null } "#, }, // From Go Standard library // Primitives TestData { input: r#"{ "numbers": [333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001, -0], "string": "\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/", "literals": [null, true, false] }"#, expected: r#"{ "numbers": [ 333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001, -0 ], "string": "\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/", "literals": [ null, true, false ] } "#, }, TestData { input: r#"{ "\u20ac": "Euro Sign", "\r": "Carriage Return", "\ufb33": "Hebrew Letter Dalet With Dagesh", "1": "One", "\ud83d\ude00": "Emoji: Grinning Face", "\u0080": "Control", "\u00f6": "Latin Small Letter O With Diaeresis" }"#, expected: r#"{ "\u20ac": "Euro Sign", "\r": "Carriage Return", "\ufb33": "Hebrew Letter Dalet With Dagesh", "1": "One", "\ud83d\ude00": "Emoji: Grinning Face", "\u0080": "Control", "\u00f6": "Latin Small Letter O With Diaeresis" } "#, }, // LargeIntegers TestData { input: " [ -9223372036854775808 , 9223372036854775807 ] ", expected: r#"[ -9223372036854775808, 9223372036854775807 ] "#, }, // Duplicates TestData { input: r#" { "0" : 0 , "1" : 1 , "0" : 0 }"#, expected: r#"{ "0": 0, "1": 1, "0": 0 } "#, }, // From jq "torture" tests TestData { input: "[0,1,[12,22,[34,[45,56],7]],[]]", expected: r#"[ 0, 1, [ 12, 22, [ 34, [ 45, 56 ], 7 ] ], [] ] "#, }, TestData { input: r#"{"a":[{"b":[]},{},[2]]}"#, expected: r#"{ "a": [ { "b": [] }, {}, [ 2 ] ] } "#, }, TestData { input: " { }", expected: "{}\n", }, TestData { input: r#"{"X":{},"Y":{},"X":{}} "#, expected: r#"{ "X": {}, "Y": {}, "X": {} } "#, }, ]; for TestData { input, expected } in datas { let mut out = String::new(); format_fmt(input.as_bytes(), Color::NoColor, &mut out).unwrap(); assert_eq!(out, expected); } } // From Go Standard library #[test] fn error_on_invalid() { struct TestData { name: &'static str, input: &'static [u8], expected_err: FormatError, expected_message: &'static str, } let datas = [ TestData { name: "Invalid start", input: b" #", expected_err: FormatError::InvalidByte(35, BytePos(1)), expected_message: "invalid byte 0x23/'#' at offset 1", }, TestData { name: "Extra comma", input: b" null , null ", expected_err: FormatError::InvalidByte(44, BytePos(6)), expected_message: "invalid byte 0x2c/',' at offset 6", }, TestData { name: "Truncated null", input: b" nul", expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Invalid null", input: b"nulL", expected_err: FormatError::InvalidByte(76, BytePos(3)), expected_message: "invalid byte 0x4c/'L' at offset 3", }, TestData { name: "Truncated false", input: b"fals", expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Invalid false", input: b"falsE", expected_err: FormatError::InvalidByte(69, BytePos(4)), expected_message: "invalid byte 0x45/'E' at offset 4", }, TestData { name: "Truncated true", input: b"tru", expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Invalid true", input: b"truE", expected_err: FormatError::InvalidByte(69, BytePos(3)), expected_message: "invalid byte 0x45/'E' at offset 3", }, TestData { name: "Invalid string", input: br#""start"#, expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Truncated string", input: br#""start"#, expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Invalid string", input: b"\"ok \x00", expected_err: FormatError::InvalidByte(0, BytePos(4)), expected_message: "invalid byte 0x00 at offset 4", }, TestData { name: "Invalid UTF-8", input: b"\"living\xde\xad\xbe\xef\"", expected_err: FormatError::InvalidUtf8([0xbe, 0xef, 0, 0], 2, BytePos(9)), expected_message: "invalid UTF-8 2 bytes 0xbe 0xef at offset 9", }, TestData { name: "Truncated number", input: b"0.", expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Invalid number", input: b"0.e", expected_err: FormatError::InvalidByte(101, BytePos(2)), expected_message: "invalid byte 0x65/'e' at offset 2", }, TestData { name: "Truncated number after start", input: b"{", expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Truncated number after start", input: b"{", expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Truncated number after name", input: br#"{"0""#, expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Truncated number after colon", input: br#"{"0":"#, expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Truncated number after value", input: br#"{"0":0"#, expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Truncated number after comma", input: br#"{"0":0,"#, expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Invalid object missing colon", input: br#" { "fizz" "buzz" } "#, expected_err: FormatError::InvalidByte(34, BytePos(10)), expected_message: "invalid byte 0x22/'\"' at offset 10", }, TestData { name: "Invalid object missing colon got comma", input: br#" { "fizz" , "buzz" } "#, expected_err: FormatError::InvalidByte(44, BytePos(10)), expected_message: "invalid byte 0x2c/',' at offset 10", }, TestData { name: "Invalid object missing colon got hash", input: br#" { "fizz" # "buzz" } "#, expected_err: FormatError::InvalidByte(35, BytePos(10)), expected_message: "invalid byte 0x23/'#' at offset 10", }, TestData { name: "Invalid object missing comma", input: br#" { "fizz" : "buzz" "gazz" } "#, expected_err: FormatError::InvalidByte(34, BytePos(19)), expected_message: "invalid byte 0x22/'\"' at offset 19", }, TestData { name: "Invalid object missing comma got colon", input: br#" { "fizz" : "buzz" : "gazz" } "#, expected_err: FormatError::InvalidByte(58, BytePos(19)), expected_message: "invalid byte 0x3a/':' at offset 19", }, TestData { name: "Invalid object missing comma got hash", input: br#" { "fizz" : "buzz" # "gazz" } "#, expected_err: FormatError::InvalidByte(35, BytePos(19)), expected_message: "invalid byte 0x23/'#' at offset 19", }, TestData { name: "Invalid object extra comma after start", input: br#" { , } "#, expected_err: FormatError::InvalidByte(44, BytePos(3)), expected_message: "invalid byte 0x2c/',' at offset 3", }, TestData { name: "Invalid object extra comma after value", input: br#" { "fizz" : "buzz" , } "#, expected_err: FormatError::InvalidByte(125, BytePos(21)), expected_message: "invalid byte 0x7d/'}' at offset 21", }, TestData { name: "Invalid object invalid name got null", input: br#" { null : null } "#, expected_err: FormatError::InvalidByte(110, BytePos(3)), expected_message: "invalid byte 0x6e/'n' at offset 3", }, TestData { name: "Invalid object invalid name got false", input: br#" { false : false } "#, expected_err: FormatError::InvalidByte(102, BytePos(3)), expected_message: "invalid byte 0x66/'f' at offset 3", }, TestData { name: "Invalid object invalid name got true", input: br#" { true : true } "#, expected_err: FormatError::InvalidByte(116, BytePos(3)), expected_message: "invalid byte 0x74/'t' at offset 3", }, TestData { name: "Invalid object invalid name got number", input: br#" { 0 : 0 } "#, expected_err: FormatError::InvalidByte(48, BytePos(3)), expected_message: "invalid byte 0x30/'0' at offset 3", }, TestData { name: "Invalid object invalid name got object", input: br#" { {} : {} } "#, expected_err: FormatError::InvalidByte(123, BytePos(3)), expected_message: "invalid byte 0x7b/'{' at offset 3", }, TestData { name: "Invalid object invalid name got array", input: br#" { [] : [] } "#, expected_err: FormatError::InvalidByte(91, BytePos(3)), expected_message: "invalid byte 0x5b/'[' at offset 3", }, TestData { name: "Invalid object mismatching delim", input: br#" { ] "#, expected_err: FormatError::InvalidByte(93, BytePos(3)), expected_message: "invalid byte 0x5d/']' at offset 3", }, TestData { name: "Invalid object mismatching delim", input: br#" { ] "#, expected_err: FormatError::InvalidByte(93, BytePos(3)), expected_message: "invalid byte 0x5d/']' at offset 3", }, TestData { name: "Truncated array after start", input: b"[", expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Truncated array after value", input: b"[0", expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Truncated array after comma", input: b"[0,", expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Invalid array missing comma", input: br#"[ "fizz" "buzz" ] "#, expected_err: FormatError::InvalidByte(34, BytePos(9)), expected_message: "invalid byte 0x22/'\"' at offset 9", }, TestData { name: "Invalid array mismatching delim", input: b" [ } ", expected_err: FormatError::InvalidByte(125, BytePos(3)), expected_message: "invalid byte 0x7d/'}' at offset 3", }, TestData { name: "Invalid delim after top level", input: br#" "", "#, expected_err: FormatError::InvalidByte(44, BytePos(3)), expected_message: "invalid byte 0x2c/',' at offset 3", }, TestData { name: "Invalid delim after begin object", input: b"{:", expected_err: FormatError::InvalidByte(58, BytePos(1)), expected_message: "invalid byte 0x3a/':' at offset 1", }, TestData { name: "Invalid delim after object name", input: br#"{"","#, expected_err: FormatError::InvalidByte(44, BytePos(3)), expected_message: "invalid byte 0x2c/',' at offset 3", }, TestData { name: "Valid delim after object name", input: br#"{"":"#, expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Invalid delim after object value", input: br#"{"":"":"#, expected_err: FormatError::InvalidByte(58, BytePos(6)), expected_message: "invalid byte 0x3a/':' at offset 6", }, TestData { name: "Valid delim after object value", input: br#"{"":"","#, expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Invalid delim after begin array", input: b"[,", expected_err: FormatError::InvalidByte(44, BytePos(1)), expected_message: "invalid byte 0x2c/',' at offset 1", }, TestData { name: "Invalid delim after array value", input: br#"["":"#, expected_err: FormatError::InvalidByte(58, BytePos(3)), expected_message: "invalid byte 0x3a/':' at offset 3", }, TestData { name: "Valid delim after array value", input: br#"["","#, expected_err: FormatError::Eof, expected_message: "unexpected end of file", }, TestData { name: "Error position", input: b"\"a\xff000\"", expected_err: FormatError::InvalidUtf8([255, 48, 48, 48], 4, BytePos(6)), expected_message: "invalid UTF-8 4 bytes 0xff 0x30 0x30 0x30 at offset 6", }, TestData { name: "Error position /0", input: b" [ \"a\xff111\" ] ", expected_err: FormatError::InvalidUtf8([255, 49, 49, 49], 4, BytePos(9)), expected_message: "invalid UTF-8 4 bytes 0xff 0x31 0x31 0x31 at offset 9", }, TestData { name: "Error position /1", input: b" [ \"a1\" , \"b\xff111\" ] ", expected_err: FormatError::InvalidUtf8([255, 49, 49, 49], 4, BytePos(16)), expected_message: "invalid UTF-8 4 bytes 0xff 0x31 0x31 0x31 at offset 16", }, TestData { name: "Error position /0/0", input: b" [ [ \"a\xff222\" ] ] ", expected_err: FormatError::InvalidUtf8([255, 50, 50, 50], 4, BytePos(11)), expected_message: "invalid UTF-8 4 bytes 0xff 0x32 0x32 0x32 at offset 11", }, TestData { name: "Error position /1/0", input: b" [ \"a1\" , [ \"a\xff222\" ] ] ", expected_err: FormatError::InvalidUtf8([255, 50, 50, 50], 4, BytePos(18)), expected_message: "invalid UTF-8 4 bytes 0xff 0x32 0x32 0x32 at offset 18", }, TestData { name: "Error position /0/1", input: b" [ [ \"a2\" , \"b\xff222\" ] ] ", expected_err: FormatError::InvalidUtf8([255, 50, 50, 50], 4, BytePos(18)), expected_message: "invalid UTF-8 4 bytes 0xff 0x32 0x32 0x32 at offset 18", }, TestData { name: "Error position /1/1", input: b" [ \"a1\" , [ \"a2\" , \"b\xff222\" ] ] ", expected_err: FormatError::InvalidUtf8([255, 50, 50, 50], 4, BytePos(25)), expected_message: "invalid UTF-8 4 bytes 0xff 0x32 0x32 0x32 at offset 25", }, TestData { name: "Error position /a1-", input: b" { \"a\xff111\" : \"b1\" } ", expected_err: FormatError::InvalidUtf8([255, 49, 49, 49], 4, BytePos(9)), expected_message: "invalid UTF-8 4 bytes 0xff 0x31 0x31 0x31 at offset 9", }, TestData { name: "Error position /a1", input: b" { \"a1\" : \"b\xff111\" } ", expected_err: FormatError::InvalidUtf8([255, 49, 49, 49], 4, BytePos(16)), expected_message: "invalid UTF-8 4 bytes 0xff 0x31 0x31 0x31 at offset 16", }, TestData { name: "Error position /c1-", input: b" { \"a1\" : \"b1\" , \"c\xff111\" : \"d1\" } ", expected_err: FormatError::InvalidUtf8([255, 49, 49, 49], 4, BytePos(23)), expected_message: "invalid UTF-8 4 bytes 0xff 0x31 0x31 0x31 at offset 23", }, TestData { name: "Error position /c1", input: b"{ \"a1\" : \"b1\" , \"c1\" : \"d\xff111\" } ", expected_err: FormatError::InvalidUtf8([255, 49, 49, 49], 4, BytePos(29)), expected_message: "invalid UTF-8 4 bytes 0xff 0x31 0x31 0x31 at offset 29", }, TestData { name: "Error position /a1/a2-", input: b" { \"a1\" : { \"a\xff222\" : \"b2\" } } ", expected_err: FormatError::InvalidUtf8([255, 50, 50, 50], 4, BytePos(18)), expected_message: "invalid UTF-8 4 bytes 0xff 0x32 0x32 0x32 at offset 18", }, TestData { name: "Error position /a1/a2", input: b" { \"a1\" : { \"a2\" : \"b\xff222\" } } ", expected_err: FormatError::InvalidUtf8([255, 50, 50, 50], 4, BytePos(25)), expected_message: "invalid UTF-8 4 bytes 0xff 0x32 0x32 0x32 at offset 25", }, TestData { name: "Error position /a1/c2-", input: b" { \"a1\" : { \"a2\" : \"b2\" , \"c\xff222\" : \"d2\" } } ", expected_err: FormatError::InvalidUtf8([255, 50, 50, 50], 4, BytePos(32)), expected_message: "invalid UTF-8 4 bytes 0xff 0x32 0x32 0x32 at offset 32", }, TestData { name: "Error position /a1/c2", input: b" { \"a1\" : { \"a2\" : \"b2\" , \"c2\" : \"d\xff222\" } } ", expected_err: FormatError::InvalidUtf8([255, 50, 50, 50], 4, BytePos(39)), expected_message: "invalid UTF-8 4 bytes 0xff 0x32 0x32 0x32 at offset 39", }, TestData { name: "Error position /1/a2", input: b" [ \"a1\" , { \"a2\" : \"b\xff222\" } ] ", expected_err: FormatError::InvalidUtf8([255, 50, 50, 50], 4, BytePos(25)), expected_message: "invalid UTF-8 4 bytes 0xff 0x32 0x32 0x32 at offset 25", }, TestData { name: "Error position /c1/1", input: b" { \"a1\" : \"b1\" , \"c1\" : [ \"a2\" , \"b\xff222\" ] } ", expected_err: FormatError::InvalidUtf8([255, 50, 50, 50], 4, BytePos(39)), expected_message: "invalid UTF-8 4 bytes 0xff 0x32 0x32 0x32 at offset 39", }, TestData { name: "Error position /0/a1/1/c3/1", input: b" [ { \"a1\" : [ \"a2\" , { \"a3\" : \"b3\" , \"c3\" : [ \"a4\" , \"b\xff444\" ] } ] } ] ", expected_err: FormatError::InvalidUtf8([255, 52, 52, 52], 4, BytePos(59)), expected_message: "invalid UTF-8 4 bytes 0xff 0x34 0x34 0x34 at offset 59", }, ]; for TestData { name, input, expected_err, expected_message, } in datas { let mut out = String::new(); let ret = format_fmt(input, Color::NoColor, &mut out); let err = ret.unwrap_err(); assert_eq!(err, expected_err, "{name}"); assert_eq!(err.to_string(), expected_message, "{name}"); } } } hurl-7.1.0/src/pretty/mod.rs000064400000000000000000000016451046102023000140540ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ pub mod json; pub use json::format; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum PrettyMode { /// Prettify based on response content type Automatic, /// Force by user, try to prettify even if there is no `Content-Type` reponse header. Force, /// No prettiyfing. None, } hurl-7.1.0/src/report/curl.rs000064400000000000000000000034141046102023000142220ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::fs::OpenOptions; use std::io::Write; use std::path::Path; use crate::report::ReportError; use crate::runner::HurlResult; use crate::util::path::create_dir_all; use crate::util::redacted::Redact; /// Creates a curl export from a list of `hurl_results`. /// /// `secrets` strings are redacted from this export. pub fn write_curl( hurl_results: &[&HurlResult], filename: &Path, secrets: &[&str], ) -> Result<(), ReportError> { create_dir_all(filename) .map_err(|e| ReportError::from_io_error(&e, filename, "Issue creating curl export"))?; let mut file = OpenOptions::new() .create(true) .truncate(true) .write(true) .append(false) .open(filename) .map_err(|e| ReportError::from_io_error(&e, filename, "Issue creating curl export"))?; let mut cmds = hurl_results .iter() .flat_map(|h| &h.entries) .map(|e| e.curl_cmd.to_string().redact(secrets)) .collect::>() .join("\n"); cmds.push('\n'); file.write_all(cmds.as_bytes()) .map_err(|e| ReportError::from_io_error(&e, filename, "Issue writing curl export"))?; Ok(()) } hurl-7.1.0/src/report/error.rs000064400000000000000000000031651046102023000144110ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::path::{Path, PathBuf}; use std::{fmt, io}; #[derive(Debug)] pub enum ReportError { IO { inner: io::ErrorKind, file: PathBuf, message: String, }, Message(String), } impl ReportError { /// Creates a new error instance. pub fn from_string(message: &str) -> Self { ReportError::Message(message.to_string()) } /// Creates a new error instance. pub fn from_io_error(error: &io::Error, file: &Path, message: &str) -> Self { ReportError::IO { inner: error.kind(), file: file.to_path_buf(), message: message.to_string(), } } } impl fmt::Display for ReportError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { ReportError::IO { inner, file, message, } => write!(f, "{message} {} ({inner})", file.display()), ReportError::Message(message) => write!(f, "{message}"), } } } hurl-7.1.0/src/report/html/mod.rs000064400000000000000000000026661046102023000150100ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ //! HTML report. mod nav; mod report; mod run; mod source; mod testcase; mod timeline; pub use report::write_report; pub use testcase::Testcase; /// The test result to be displayed in an HTML page #[derive(Clone, Debug, PartialEq, Eq)] struct HTMLResult { /// Original filename, as given in the run execution filename: String, /// The id of the corresponding [`Testcase`] id: String, time_in_ms: u128, success: bool, timestamp: i64, } impl HTMLResult { /// Creates a new HTMLResult from a [`Testcase`]. fn from(testcase: &Testcase) -> Self { HTMLResult { filename: testcase.filename.clone(), id: testcase.id.clone(), time_in_ms: testcase.time_in_ms, success: testcase.success, timestamp: testcase.timestamp, } } } hurl-7.1.0/src/report/html/nav.rs000064400000000000000000000125051046102023000150060ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::ast::SourceInfo; use hurl_core::error::{DisplaySourceError, OutputFormat}; use crate::report::html::Testcase; use crate::runner::RunnerError; use crate::util::redacted::Redact; #[derive(Copy, Clone, Eq, PartialEq)] pub enum Tab { Timeline, Run, Source, } impl Testcase { /// Returns the HTML navigation component for a `tab`. /// This common component is used to get source information and errors. pub fn get_nav_html(&self, content: &str, tab: Tab, secrets: &[&str]) -> String { let status = get_status_html(self.success, &self.id); let errors = self.get_errors_html(content, secrets); let errors_count = if !self.errors.is_empty() { self.errors.len().to_string() } else { "-".to_string() }; format!( include_str!("resources/nav.html"), duration = self.time_in_ms, errors = errors, errors_count = errors_count, filename = self.filename, href_run = self.run_filename(), href_source = self.source_filename(), href_timeline = self.timeline_filename(), run_selected = tab == Tab::Run, source_selected = tab == Tab::Source, status = status, timeline_selected = tab == Tab::Timeline, ) } /// Formats a list of Hurl errors to HTML snippet. fn get_errors_html(&self, content: &str, secrets: &[&str]) -> String { self.errors .iter() .map(|(error, entry_src_info)| { let error = error_to_html( error, *entry_src_info, content, &self.filename, &self.source_filename(), secrets, ); format!("
{error}
") }) .collect::>() .join("") } } fn get_status_html(success: bool, id: &str) -> String { let class = if success { "success" } else { "failure" }; let label = if success { "success" } else { "failure" }; format!("{label}") } /// Returns an HTML `
` tag representing this `error`.
fn error_to_html(
    error: &RunnerError,
    entry_src_info: SourceInfo,
    content: &str,
    filename: &str,
    source_filename: &str,
    secrets: &[&str],
) -> String {
    let line = error.source_info.start.line;
    let column = error.source_info.start.column;
    let message = error.render(
        filename,
        content,
        Some(entry_src_info),
        OutputFormat::Terminal(false),
    );
    let message = message.redact(secrets);
    let message = html_escape(&message);
    // We override the first part of the error string to add an anchor to
    // the error context.
    let old = format!("{filename}:{line}:{column}");
    let href = source_filename;
    let new = format!("{filename}:{line}:{column}");
    let message = message.replace(&old, &new);
    format!("
{message}
") } /// Escapes '<' and '>' from `text`. fn html_escape(text: &str) -> String { text.replace('<', "<").replace('>', ">") } #[cfg(test)] mod tests { use hurl_core::ast::SourceInfo; use hurl_core::reader::Pos; use crate::report::html::nav::error_to_html; use crate::runner::{RunnerError, RunnerErrorKind}; #[test] fn test_error_html() { let entry_src_info = SourceInfo::new(Pos::new(1, 1), Pos::new(1, 39)); let error = RunnerError::new( SourceInfo::new(Pos::new(4, 1), Pos::new(4, 9)), RunnerErrorKind::AssertFailure { actual: "".to_string(), expected: "Hello world".to_string(), type_mismatch: false, }, true, ); let content = "GET http://localhost:8000/inline-script\n\ HTTP 200\n\ [Asserts]\n\ `Hello World`\n\ "; let filename = "a/b/c/foo.hurl"; let source_filename = "abc-source.hurl"; let html = error_to_html( &error, entry_src_info, content, filename, source_filename, &[], ); assert_eq!( html, r##"
Assert failure
  --> a/b/c/foo.hurl:4:1
   |
   | GET http://localhost:8000/inline-script
   | ...
 4 | `Hello World`
   |   actual:   <script>alert('Hi')</script>
   |   expected: Hello world
   |
"## ); } } hurl-7.1.0/src/report/html/report.rs000064400000000000000000000220361046102023000155350ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::report::html::{HTMLResult, Testcase}; use crate::report::ReportError; use chrono::{DateTime, Local}; use regex::Regex; use std::io::Write; use std::path::Path; use std::sync::LazyLock; /// Creates and HTML report for this list of [`Testcase`] at `dir_path`/index.html. /// /// If the report already exists, results are merged. pub fn write_report(dir_path: &Path, testcases: &[Testcase]) -> Result<(), ReportError> { let index_path = dir_path.join("index.html"); let mut results = parse_html(&index_path)?; for testcase in testcases.iter() { let html_result = HTMLResult::from(testcase); results.push(html_result); } let now = Local::now(); let s = create_html_index(&now.to_rfc2822(), &results); let file_path = index_path; let mut file = std::fs::File::create(&file_path) .map_err(|e| ReportError::from_io_error(&e, &file_path, "Issue writing HTML report"))?; file.write_all(s.as_bytes()) .map_err(|e| ReportError::from_io_error(&e, &file_path, "Issue writing HTML report"))?; Ok(()) } /// Returns a standalone HTML report from the list of `hurl_results`. fn create_html_index(now: &str, hurl_results: &[HTMLResult]) -> String { let count_total = hurl_results.len(); let count_failure = hurl_results.iter().filter(|result| !result.success).count(); let count_success = hurl_results.iter().filter(|result| result.success).count(); let percentage_success = percentage(count_success, count_total); let percentage_failure = percentage(count_failure, count_total); let css = include_str!("resources/report.css"); let rows = hurl_results .iter() .map(create_html_table_row) .collect::>() .join(""); format!( include_str!("resources/report.html"), now = now, css = css, count_total = count_total, count_success = count_success, count_failure = count_failure, percentage_success = percentage_success, percentage_failure = percentage_failure, rows = rows, ) } fn parse_html(path: &Path) -> Result, ReportError> { if !path.exists() { return Ok(vec![]); } let s = std::fs::read_to_string(path) .map_err(|e| ReportError::from_io_error(&e, path, "Issue reading HTML report"))?; Ok(parse_html_report(&s)) } static TEST_REF: LazyLock = LazyLock::new(|| { Regex::new( r#"(?x) data-duration="(?P\d+)" \s+ data-status="(?P[a-z]+)" \s+ data-filename="(?P[\p{L}\p{N}\p{M}\p{S}\p{P}\p{Zs}_./-]+)" \s+ data-id="(?P[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})" (\s+ data-timestamp="(?P[0-9]{1,10})")? "#, ) .unwrap() }); /// Parses the HTML report `html` and returns a list of [`HTMLResult`]. fn parse_html_report(html: &str) -> Vec { // TODO: if the existing HTML report is not valid, we consider that there is no // existing report to append, without displaying any error or warning. Maybe a better option // would be to raise an error here and ask the user to explicitly deal with this error. TEST_REF .captures_iter(html) .map(|cap| { let filename = cap["filename"].to_string(); let id = cap["id"].to_string(); let time_in_ms = cap["time_in_ms"].to_string().parse().unwrap(); let success = &cap["status"] == "success"; // Older reports won't have this so make it optional let timestamp: i64 = cap .name("timestamp") .map_or(0, |m| m.as_str().parse().unwrap()); HTMLResult { filename, id, time_in_ms, success, timestamp, } }) .collect::>() } fn create_html_table_row(result: &HTMLResult) -> String { let status = if result.success { "success".to_string() } else { "failure".to_string() }; let duration_in_ms = result.time_in_ms; let duration_in_s = result.time_in_ms as f64 / 1000.0; let filename = &result.filename; let displayed_filename = if filename == "-" { "(standard input)" } else { filename }; let id = &result.id; let timestamp = result.timestamp; let displayed_time = if timestamp == 0 { "-".to_string() } else { DateTime::from_timestamp(timestamp, 0) .unwrap() .naive_local() .and_local_timezone(Local) .unwrap() .to_rfc3339() }; format!( r#" {displayed_filename} {status} {displayed_time} {duration_in_s} "# ) } fn percentage(count: usize, total: usize) -> String { format!("{:.1}%", (count as f32 * 100.0) / total as f32) } #[cfg(test)] mod tests { use super::*; #[test] fn test_percentage() { assert_eq!(percentage(100, 100), "100.0%".to_string()); assert_eq!(percentage(66, 99), "66.7%".to_string()); assert_eq!(percentage(33, 99), "33.3%".to_string()); } #[test] fn test_parse_html_report() { let html = r#"

Hurl Report

tests/hello.hurl success 0.1s
tests/failure.hurl failure 2023-10-05T02:37:24Z 0.2s
tests/café.hurl success 2023-10-05T02:37:24Z 0.4s
abcd[1234]@2x.hurl failure 2023-10-05T02:37:24Z 0.4s
"#; assert_eq!( parse_html_report(html), vec![ HTMLResult { filename: "tests/hello.hurl".to_string(), id: "08aad14a-8d10-4ecc-892e-a72703c5b494".to_string(), time_in_ms: 100, success: true, timestamp: 0, }, HTMLResult { filename: "tests/failure.hurl".to_string(), id: "a6641ae3-8ce0-4d9f-80c5-3e23e032e055".to_string(), time_in_ms: 200, success: false, timestamp: 1696473444, }, HTMLResult { filename: "tests/café.hurl".to_string(), id: "a151aea6-2b02-465e-be47-45a2fa9cce02".to_string(), time_in_ms: 50, success: true, timestamp: 1796473444, }, HTMLResult { filename: "abcd[1234]@2x.hurl".to_string(), id: "2008c777-025d-4708-8016-e2928b9ef538".to_string(), time_in_ms: 366, success: false, timestamp: 1796473666, }, ] ); } } hurl-7.1.0/src/report/html/resources/calls.css000064400000000000000000000002701046102023000174720ustar 00000000000000@media (prefers-color-scheme: dark) { .calls-list { fill: #c2c2c2; } .calls-back { fill: #27272c; } .calls-grid rect { fill: #444; } }hurl-7.1.0/src/report/html/resources/nav.css000064400000000000000000000021241046102023000171600ustar 00000000000000.report-nav { margin-top: 20px; margin-bottom: 20px; } .report-nav-links { display: flex; margin-bottom: 20px; font-weight: bold; } .report-nav a { color: royalblue; margin-right: 20px; } .report-nav a[aria-selected="true"] { color: #ff0288; } .report-nav-summary > div { display: flex; } .report-nav-summary .item-name { min-width: 100px; font-weight: bold; } .error { margin-top: 10px; margin-bottom: 10px; border-left: red 4px solid; } .error-desc { background: #f5f5f5; } .error-desc pre { font-size: 0.8rem; line-height: 1.2; margin: 0.75rem; padding: 0.8rem; overflow-x: auto; } .error-desc pre code { font-size: 0.8rem; line-height: 1.2; } .success, .success a { color: green; } .failure, .failure a { color: red; } @media (prefers-color-scheme: dark) { .report-nav a { color: #34a7ff; } .success, .success a { color: green; } .report-nav a[aria-selected="true"] { color: #ff0288; } .error-desc { background: #27272c; } } hurl-7.1.0/src/report/html/resources/nav.html000064400000000000000000000015111046102023000173330ustar 00000000000000
Status:
{status}
Duration:
{duration} ms
Errors:
{errors_count}
{errors}
hurl-7.1.0/src/report/html/resources/report.css000064400000000000000000000013521046102023000177110ustar 00000000000000body { font-family: "Helvetica Neue", Arial, sans-serif; font-size: 1.125rem; line-height: 1.4; } .container { max-width: 1200px; width: 100%; margin-left: auto; margin-right: auto; } h2 { color: #ff0288; font-size: 2.5rem; } .summary { margin: 32px 0 32px 0; font-size: 1.25rem; } a { color: royalblue; } @media (prefers-color-scheme: dark) { a { color: #34a7ff; } } .date { margin-bottom: 20px; } td { padding: 4px 8px 4px 0; } thead { font-weight: bold; } .success, .success a { color: green; } .failure, .failure a { color: red; } @media (prefers-color-scheme: dark) { body { background-color: #19191c; color: #c2c2c2; } } hurl-7.1.0/src/report/html/resources/report.html000064400000000000000000000013151046102023000200640ustar 00000000000000Test Report

Report

{now}
Executed: {count_total} (100%)
Succeeded: {count_success} ({percentage_success})
Failed: {count_failure} ({percentage_failure})
{rows}
File Status Start Time Duration
hurl-7.1.0/src/report/html/resources/run.css000064400000000000000000000023741046102023000172070ustar 00000000000000body { font-family: "Helvetica Neue", Arial, sans-serif; font-size: 1.125rem; line-height: 1.4; } .container { max-width: 1200px; width: 100%; margin-left: auto; margin-right: auto; } h4:target { color: #ff0288; } table { display: block; font-size: 15px; width: 100%; max-width: 100%; overflow: auto; border-collapse: collapse; margin-top: 16px; margin-bottom: 16px; } th, td { border-width: 1px; border-style: solid; border-color: #ddd; } th { padding: 6px 8px; text-align: left; background: #f5f5f5; } td { padding: 6px 8px; vertical-align: text-top; } .name { width: 120px; font-weight: bold; background: #fbfafd; } .value { width: 800px; word-break: break-all } details { margin-bottom: 20px; } summary { font-size: 1.3rem; line-height: 1.4; font-weight: bold; } @media (prefers-color-scheme: dark) { body { background-color: #19191c; color: #c2c2c2; } th, td { border-color: #444; } th, table tr:nth-child(2n) { background-color: #27272c; } table tr:nth-child(2n+1) { background-color: #19191c; } .name { background-color: #19191c; } } hurl-7.1.0/src/report/html/resources/run.html000064400000000000000000000004041046102023000173530ustar 00000000000000 {filename}
{nav} {run}
hurl-7.1.0/src/report/html/resources/source.css000064400000000000000000000022131046102023000176730ustar 00000000000000body { font-family: "Helvetica Neue", Arial, sans-serif; font-size: 1.125rem; line-height: 1.4; } .line-error { border-bottom: red 2px dashed; } .line-error::before { content: "⛔ " } .line-numbers a.line-error { color: red; } .container { max-width: 1200px; width: 100%; margin-left: auto; margin-right: auto; } .source-container { display: flex; padding: 0; border: solid 1px #dcdcde; } .line-numbers { text-align: right; padding: 8px 10px; border-right: solid 1px #dcdcde; background: #fbfafd; } .line-numbers a { color: #89888d; text-decoration: none; } .line-numbers a:hover { text-decoration: underline; } .source { padding: 8px 10px; overflow: auto; overflow-y: hidden; } @media (prefers-color-scheme: dark) { body { background-color: #19191c; color: #c2c2c2; } .source-container { border-color: #444; background-color: #27272c; } .line-numbers { padding: 8px 10px; border-right-color: #444; background: #19191c; } .line-numbers a { color: dimgray; } } hurl-7.1.0/src/report/html/resources/source.html000064400000000000000000000006551046102023000200570ustar 00000000000000 {filename}
{nav}
{lines_div}
{source_div}
hurl-7.1.0/src/report/html/resources/timeline.css000064400000000000000000000013101046102023000201760ustar 00000000000000body { font-family: "Helvetica Neue", Arial, sans-serif; font-size: 1.125rem; line-height: 1.4; } .container { max-width: 1200px; width: 100%; margin-left: auto; margin-right: auto; } .timeline-container { max-width: 1400px; width: 100%; margin-left: auto; margin-right: auto; border: solid 1px #dcdcde; display: flex; } .calls { position: sticky; left: 0; right: 0; width: 260px; flex-shrink: 0; } .waterfall { overflow: auto; overflow-y: hidden; } @media (prefers-color-scheme: dark) { body { background-color: #19191c; color: #c2c2c2; } .timeline-container { border-color: #444; } } hurl-7.1.0/src/report/html/resources/timeline.html000064400000000000000000000006051046102023000203600ustar 00000000000000 {filename}
{nav}
{calls}
{waterfall}
hurl-7.1.0/src/report/html/resources/waterfall.css000064400000000000000000000007131046102023000203570ustar 00000000000000.call-detail { display: none; } .call-summary:hover + .call-detail, .call-detail:hover { display: block; } .call-sel { pointer-events: none; } @media (prefers-color-scheme: dark) { .grid-strip rect { fill: #27272c; } .grid-ticks { stroke: #444; } .call-back { stroke: #444; fill: #19191c; } .call-sel { opacity: 0.1; } .call-legend { fill: #c2c2c2; } }hurl-7.1.0/src/report/html/run.rs000064400000000000000000000143461046102023000150330ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::ast::HurlFile; use crate::http::Call; use crate::report::html::nav::Tab; use crate::report::html::Testcase; use crate::runner::EntryResult; use crate::util::redacted::Redact; impl Testcase { /// Creates an HTML view of a run (HTTP status code, response header etc...) pub fn get_run_html( &self, hurl_file: &HurlFile, content: &str, entries: &[EntryResult], secrets: &[&str], ) -> String { let nav = self.get_nav_html(content, Tab::Run, secrets); let nav_css = include_str!("resources/nav.css"); let run_css = include_str!("resources/run.css"); let mut run = String::new(); for (entry_index, e) in entries.iter().enumerate() { let entry_src_index = e.entry_index.to_zero_based(); let entry_src = hurl_file.entries.get(entry_src_index).unwrap(); let line = entry_src.source_info().start.line; let source = self.source_filename(); run.push_str("
"); let info = get_entry_html(e, entry_index + 1, secrets); run.push_str(&info); for (call_index, c) in e.calls.iter().enumerate() { let info = get_call_html( c, entry_index + 1, call_index + 1, &self.filename, &source, line, secrets, ); run.push_str(&info); } run.push_str("
"); } format!( include_str!("resources/run.html"), filename = self.filename, nav = nav, nav_css = nav_css, run = run, run_css = run_css, ) } } /// Returns an HTML view of an `entry` information as HTML (title, `entry_index` and captures). fn get_entry_html(entry: &EntryResult, entry_index: usize, secrets: &[&str]) -> String { let mut text = String::new(); text.push_str(&format!("Entry {entry_index}")); let cmd = entry.curl_cmd.to_string().redact(secrets); let table = new_table("Debug", &[("Command", &cmd)]); text.push_str(&table); if !entry.captures.is_empty() { let mut values = entry .captures .iter() .map(|c| (&c.name, c.value.to_string().redact(secrets))) .collect::>(); values.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); let table = new_table("Captures", &values); text.push_str(&table); } text } /// Returns an HTML view of a `call` (source file, request and response headers, certificate etc...) fn get_call_html( call: &Call, entry_index: usize, call_index: usize, filename: &str, source: &str, line: usize, secrets: &[&str], ) -> String { let mut text = String::new(); let id = format!("e{entry_index}:c{call_index}"); text.push_str(&format!("

Call {call_index}

")); // General let status = call.response.status.to_string(); let version = call.response.version.to_string(); let url = &call.request.url.to_string().redact(secrets); let url = format!("{url}"); let source = format!("{filename}:{line}"); let start_date = call.timings.begin_call.to_rfc2822(); let values = vec![ ("Start Date", start_date.as_str()), ("Request URL", url.as_str()), ("Request Method", call.request.method.as_str()), ("Version", version.as_str()), ("Status code", status.as_str()), ("Source", source.as_str()), ]; let table = new_table("General", &values); text.push_str(&table); // Certificate if let Some(certificate) = &call.response.certificate { let start_date = certificate.start_date.to_string(); let end_date = certificate.expire_date.to_string(); let values = vec![ ("Subject", certificate.subject.as_str()), ("Issuer", certificate.issuer.as_str()), ("Start Date", start_date.as_str()), ("Expire Date", end_date.as_str()), ("Serial Number", certificate.serial_number.as_str()), ]; let table = new_table("Certificate", &values); text.push_str(&table); } let mut values = call .request .headers .iter() .map(|h| (h.name.as_str(), h.value.redact(secrets))) .collect::>(); values.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); let table = new_table("Request Headers", &values); text.push_str(&table); let mut values = call .response .headers .iter() .map(|h| (h.name.as_str(), h.value.redact(secrets))) .collect::>(); values.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); let table = new_table("Response Headers", &values); text.push_str(&table); text } /// Returns an HTML table with a `title` and a list of key/values. Values are redacted using `secrets`. fn new_table, U: AsRef + std::fmt::Display>( title: &str, data: &[(T, U)], ) -> String { let mut text = String::new(); text.push_str(&format!( "" )); data.iter().for_each(|(name, value)| { text.push_str(&format!( "", name.as_ref(), value )); }); text.push_str("
{title}
{}{}
"); text } hurl-7.1.0/src/report/html/source.rs000064400000000000000000000047131046102023000155240ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use hurl_core::ast::{HurlFile, SourceInfo}; use crate::report::html::nav::Tab; use crate::report::html::Testcase; use crate::runner::RunnerError; impl Testcase { /// Returns the HTML string of the Hurl source file (syntax colored and errors). pub fn get_source_html(&self, hurl_file: &HurlFile, content: &str, secrets: &[&str]) -> String { let nav = self.get_nav_html(content, Tab::Source, secrets); let nav_css = include_str!("resources/nav.css"); let source_div = hurl_core::format::format_html(hurl_file, false); let lines_div = get_numbered_lines(content, &self.errors); let source_css = include_str!("resources/source.css"); let hurl_css = hurl_core::format::hurl_css(); format!( include_str!("resources/source.html"), filename = self.filename, hurl_css = hurl_css, lines_div = lines_div, nav = nav, nav_css = nav_css, source_div = source_div, source_css = source_css, ) } } /// Returns a list of lines number in HTML. fn get_numbered_lines(content: &str, errors: &[(RunnerError, SourceInfo)]) -> String { let errors = errors .iter() .map(|(error, _)| error.source_info.start.line) .collect::>(); let mut lines = content .lines() .enumerate() .fold("
".to_string(), |acc, (count, _)| -> String {
                let line = count + 1;
                let tag = if errors.contains(&line) {
                    format!("{line}\n")
                } else {
                    format!("{line}\n")
                };
                acc + &tag
            });
    lines.push_str("
"); lines } hurl-7.1.0/src/report/html/testcase.rs000064400000000000000000000077201046102023000160400ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::fs; use std::path::Path; use crate::report::ReportError; use crate::runner::{EntryResult, HurlResult, RunnerError}; use hurl_core::ast::SourceInfo; use hurl_core::input::Input; use hurl_core::parser; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Testcase { /// Unique identifier of this testcase. pub id: String, /// Source file name. pub filename: String, pub success: bool, pub time_in_ms: u128, /// The runtime errors, and the source information of the entry throwing this error. pub errors: Vec<(RunnerError, SourceInfo)>, pub timestamp: i64, } impl Testcase { /// Creates an HTML testcase. pub fn from(hurl_result: &HurlResult, filename: &Input) -> Testcase { let id = Uuid::new_v4(); let errors = hurl_result .errors() .into_iter() .map(|(error, entry_src_info)| (error.clone(), entry_src_info)) .collect(); Testcase { id: id.to_string(), filename: filename.to_string(), time_in_ms: hurl_result.duration.as_millis(), success: hurl_result.success, errors, timestamp: hurl_result.timestamp, } } /// Exports a [`Testcase`] to HTML in the directory `dir`. /// /// It will create three HTML files: /// - an HTML view of the Hurl source file (with potential errors and syntax colored), /// - an HTML timeline view of the executed entries (with potential errors, waterfall) /// - an HTML view of the executed run (headers, cookies, etc...) /// /// `secrets` strings are redacted from the produced HTML. pub fn write_html( &self, content: &str, entries: &[EntryResult], dir: &Path, secrets: &[&str], ) -> Result<(), ReportError> { // We parse the content as we'll reuse the AST to construct the HTML source file, and // the waterfall. // TODO: for the moment, we can only have parseable file. let hurl_file = parser::parse_hurl_file(content).unwrap(); // We create the timeline view. let output_file = dir.join(self.timeline_filename()); let html = self.get_timeline_html(&hurl_file, content, entries, secrets); fs::write(&output_file, html.as_bytes()).map_err(|e| { ReportError::from_io_error(&e, &output_file, "Issue writing HTML report") })?; // Then create the run view. let output_file = dir.join(self.run_filename()); let html = self.get_run_html(&hurl_file, content, entries, secrets); fs::write(&output_file, html.as_bytes()).map_err(|e| { ReportError::from_io_error(&e, &output_file, "Issue writing HTML report") })?; // And create the source view. let output_file = dir.join(self.source_filename()); let html = self.get_source_html(&hurl_file, content, secrets); fs::write(&output_file, html.as_bytes()).map_err(|e| { ReportError::from_io_error(&e, &output_file, "Issue writing HTML report") })?; Ok(()) } pub fn source_filename(&self) -> String { format!("{}-source.html", self.id) } pub fn timeline_filename(&self) -> String { format!("{}-timeline.html", self.id) } pub fn run_filename(&self) -> String { format!("{}-run.html", self.id) } } hurl-7.1.0/src/report/html/timeline/calls.rs000064400000000000000000000146431046102023000171330ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::iter::zip; use crate::http::Call; use crate::report::html::timeline::svg::Attribute::{ Class, Fill, FontFamily, FontSize, Height, Href, TextDecoration, ViewBox, Width, X, Y, }; use crate::report::html::timeline::svg::{Element, ElementKind}; use crate::report::html::timeline::unit::{Pixel, Px}; use crate::report::html::timeline::util::{ new_failure_icon, new_retry_icon, new_success_icon, trunc_str, }; use crate::report::html::timeline::{svg, CallContext, CallContextKind, CALL_HEIGHT}; use crate::report::html::Testcase; use crate::util::redacted::Redact; impl Testcase { /// Returns a SVG view of `calls` list using contexts `call_ctxs`. pub fn get_calls_svg( &self, calls: &[&Call], call_ctxs: &[CallContext], secrets: &[&str], ) -> String { let margin_top = 50.px(); let margin_bottom = 250.px(); let call_height = 24.px(); let width = 260.px(); let height = call_height * calls.len() + margin_top + margin_bottom; let height = Pixel::max(100.px(), height); let mut root = svg::new_svg(); root.add_attr(ViewBox(0.0, 0.0, width.0, height.0)); root.add_attr(Width(width.0.to_string())); root.add_attr(Height(height.0.to_string())); // Add styles, symbols for success and failure icons: let elt = svg::new_style(include_str!("../resources/calls.css")); root.add_child(elt); let symbol = new_success_icon("success"); root.add_child(symbol); let symbol = new_failure_icon("failure"); root.add_child(symbol); let symbol = new_retry_icon("retry"); root.add_child(symbol); // Add a flat background. let mut elt = Element::new(ElementKind::Rect); elt.add_attr(Class("calls-back".to_string())); elt.add_attr(X(0.0)); elt.add_attr(Y(0.0)); elt.add_attr(Width("100%".to_string())); elt.add_attr(Height("100%".to_string())); elt.add_attr(Fill("#fbfafd".to_string())); root.add_child(elt); if !calls.is_empty() { // Add horizontal lines let x = 0.px(); let y = margin_top; let elt = new_grid(calls, y, width, height); root.add_child(elt); // Add calls info let elt = new_calls(calls, call_ctxs, x, y, secrets); root.add_child(elt); } root.to_string() } } /// Returns an SVG view of a list of `call`. /// For instance: /// /// `✅ GET www.google.fr 303 ` fn new_calls( calls: &[&Call], call_ctxs: &[CallContext], offset_x: Pixel, offset_y: Pixel, secrets: &[&str], ) -> Element { let mut group = svg::new_group(); group.add_attr(Class("calls-list".to_string())); group.add_attr(FontSize("13px".to_string())); group.add_attr(FontFamily("sans-serif".to_string())); group.add_attr(Fill("#777".to_string())); let margin_left = 13.px(); zip(calls, call_ctxs) .enumerate() .for_each(|(index, (call, call_ctx))| { let mut x = offset_x + margin_left; let y = offset_y + (CALL_HEIGHT * index) + CALL_HEIGHT - 7.px(); // Icon success / failure let mut elt = svg::new_use(); let icon = match call_ctx.kind { CallContextKind::Success => "#success", CallContextKind::Failure => "#failure", CallContextKind::Retry => "#retry", }; elt.add_attr(Href(icon.to_string())); elt.add_attr(X(x.0 - 6.0)); elt.add_attr(Y(y.0 - 11.0)); elt.add_attr(Width("13".to_string())); elt.add_attr(Height("13".to_string())); group.add_child(elt); x += 12.px(); // URL let url = &call.request.url.to_string().redact(secrets); let url = url.strip_prefix("http://").unwrap_or(url); let url = url.strip_prefix("https://").unwrap_or(url); let full_text = format!("{} {url}", call.request.method); let text = trunc_str(&full_text, 24); let mut elt = svg::new_text(x.0, y.0, &text); if call_ctx.kind == CallContextKind::Failure { elt.add_attr(Fill("red".to_string())); } let title = svg::new_title(&full_text); elt.add_child(title); group.add_child(elt); // Status code x += 180.px(); let text = format!("{}", call.response.status); let mut elt = svg::new_text(x.0, y.0, &text); if call_ctx.kind == CallContextKind::Failure { elt.add_attr(Fill("red".to_string())); } group.add_child(elt); // Source x += 28.px(); let href = format!( "{}#e{}:c{}", call_ctx.run_filename, call_ctx.entry_index, call_ctx.call_entry_index ); let mut a = svg::new_a(&href); let mut text = svg::new_text(x.0, y.0, "run"); text.add_attr(Fill("royalblue".to_string())); text.add_attr(TextDecoration("underline".to_string())); a.add_child(text); group.add_child(a); }); group } /// Returns a SVG view of the grid calls. fn new_grid(calls: &[&Call], offset_y: Pixel, width: Pixel, height: Pixel) -> Element { let mut group = svg::new_group(); group.add_attr(Class("calls-grid".to_string())); let nb_lines = 2 * (calls.len() / 2) + 2; (0..nb_lines).for_each(|index| { let y = CALL_HEIGHT * index + offset_y - (index % 2).px(); let elt = svg::new_rect(0.0, y.0, width.0, 1.0, "#ddd"); group.add_child(elt); }); // Right borders: let elt = svg::new_rect(width.0 - 1.0, 0.0, 1.0, height.0, "#ddd"); group.add_child(elt); group } hurl-7.1.0/src/report/html/timeline/mod.rs000064400000000000000000000112311046102023000166020ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use crate::http::Call; use crate::report::html::nav::Tab; use crate::report::html::timeline::unit::Pixel; use crate::report::html::Testcase; use crate::runner::EntryResult; use hurl_core::ast::HurlFile; use hurl_core::types::Index; mod calls; mod nice; mod svg; mod unit; mod util; mod waterfall; /// Some common constants used to construct our SVG timeline. const CALL_HEIGHT: Pixel = Pixel(24.0); const CALL_INSET: Pixel = Pixel(3.0); #[derive(Copy, Clone, Eq, PartialEq)] pub enum CallContextKind { Success, // call context parent entry is successful Failure, // call context parent entry is in error and has not been retried Retry, // call context parent entry is in error and has been retried } /// A structure that holds information to construct a SVG view /// of a [`Call`] pub struct CallContext { pub kind: CallContextKind, // If the parent entry is successful, retried or in error. pub line: Index, // Line number of the source entry pub entry_index: Index, // Index of the runtime EntryResult pub call_entry_index: Index, // Index of the runtime Call in the current entry pub call_index: Index, // Index of the runtime Call in the whole run pub source_filename: String, pub run_filename: String, } impl Testcase { /// Returns the HTML timeline of these `entries`. /// The AST `hurl_file` is used to construct URL with line numbers to the corresponding /// entry in the colored HTML source file. pub fn get_timeline_html( &self, hurl_file: &HurlFile, content: &str, entries: &[EntryResult], secrets: &[&str], ) -> String { let calls = entries .iter() .flat_map(|e| &e.calls) .collect::>(); let call_ctxs = self.get_call_contexts(hurl_file, entries); let timeline_css = include_str!("../resources/timeline.css"); let nav = self.get_nav_html(content, Tab::Timeline, secrets); let nav_css = include_str!("../resources/nav.css"); let calls_svg = self.get_calls_svg(&calls, &call_ctxs, secrets); let waterfall_svg = self.get_waterfall_svg(&calls, &call_ctxs, secrets); format!( include_str!("../resources/timeline.html"), calls = calls_svg, filename = self.filename, nav = nav, nav_css = nav_css, timeline_css = timeline_css, waterfall = waterfall_svg, ) } /// Constructs a list of call contexts to record source line code, runtime entry and call indices. fn get_call_contexts(&self, hurl_file: &HurlFile, entries: &[EntryResult]) -> Vec { let mut calls_ctx = vec![]; for (entry_index, e) in entries.iter().enumerate() { let next_e = entries.get(entry_index + 1); let retry = match next_e { None => false, // last entry of the whole run can't be retried Some(next_e) => e.entry_index == next_e.entry_index, }; let kind = match (e.errors.is_empty(), retry) { (true, _) => CallContextKind::Success, (false, true) => CallContextKind::Retry, (false, false) => CallContextKind::Failure, }; for (call_entry_index, _) in e.calls.iter().enumerate() { let entry_src_index = e.entry_index.to_zero_based(); let entry_src = hurl_file.entries.get(entry_src_index).unwrap(); let line = Index::new(entry_src.source_info().start.line); let ctx = CallContext { kind, line, entry_index: Index::from_zero_based(entry_index), call_entry_index: Index::from_zero_based(call_entry_index), call_index: Index::from_zero_based(calls_ctx.len()), source_filename: self.source_filename(), run_filename: self.run_filename(), }; calls_ctx.push(ctx); } } calls_ctx } } hurl-7.1.0/src/report/html/timeline/nice.rs000064400000000000000000000066461046102023000167570ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ /// A structure that holds "nice" numbers given a minimum and maximum values, and a number of ticks. /// The code is derived from "Graphics Gems, Volume 1" by Andrew S. Glassner /// See: /// - /// - #[derive(Copy, Clone, PartialEq, Debug)] pub struct NiceScale { min_value: f64, max_value: f64, max_ticks: usize, range: f64, tick_spacing: f64, nice_min: f64, nice_max: f64, } impl NiceScale { pub fn new(min_value: f64, max_value: f64, max_ticks: usize) -> Self { let range = max_value - min_value; let range = nice_number(range, false); let tick_spacing = range / ((max_ticks - 1) as f64); let tick_spacing = nice_number(tick_spacing, true); let nice_min = (min_value / tick_spacing).floor() * tick_spacing; let nice_max = (max_value / tick_spacing).ceil() * tick_spacing; NiceScale { min_value, max_value, max_ticks, range, tick_spacing, nice_min, nice_max, } } pub fn get_tick_spacing(&self) -> f64 { self.tick_spacing } } /// Returns a 'nice' number approximately equal to `range`. /// Rounds the number if `round` is true, otherwise take the ceiling. fn nice_number(range: f64, round: bool) -> f64 { let exponent = range.log10().floor() as i32; let fraction = range / 10_f64.powi(exponent); let nice_fraction = if round { if fraction < 1.5 { 1.0 } else if fraction < 3.0 { 2.0 } else if fraction < 7.0 { 5.0 } else { 10.0 } } else if fraction <= 1.0 { 1.0 } else if fraction <= 2.0 { 2.0 } else if fraction <= 5.0 { 5.0 } else { 10.0 }; nice_fraction * 10_f64.powi(exponent) } #[cfg(test)] mod tests { use crate::report::html::timeline::nice::NiceScale; #[test] fn test_nice_scale() { let ns = NiceScale::new(0.0, 500.0, 20); assert_eq!( ns, NiceScale { min_value: 0.0, max_value: 500.0, max_ticks: 20, range: 500.0, tick_spacing: 20.0, nice_min: 0.0, nice_max: 500.0, } ); let ns = NiceScale::new(0.0, 1700.0, 20); assert_eq!( ns, NiceScale { min_value: 0.0, max_value: 1700.0, max_ticks: 20, range: 2000.0, tick_spacing: 100.0, nice_min: 0.0, nice_max: 1700.0, } ); } } hurl-7.1.0/src/report/html/timeline/svg.rs000064400000000000000000000365521046102023000166370ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * 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. * */ use std::fmt; use std::slice::Iter; /// Represents a SVG element. This list is __partial__, and contains only /// elements necessary to Hurl waterfall export. /// See . #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum ElementKind { A, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/a Defs, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs FeDropShadow, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/feDropShadow Filter, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/filter Group, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g Line, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/line Path, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path Rect, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/rect Style, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/style Svg, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/svg Symbol, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/symbol Text, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text Title, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/title Use, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use } impl ElementKind { /// Returns the XML tag name of this SVG element. pub fn name(&self) -> &'static str { match self { ElementKind::A => "a", ElementKind::Defs => "defs", ElementKind::Filter => "filter", ElementKind::FeDropShadow => "feDropShadow", ElementKind::Group => "g", ElementKind::Line => "line", ElementKind::Path => "path", ElementKind::Rect => "rect", ElementKind::Style => "style", ElementKind::Svg => "svg", ElementKind::Symbol => "symbol", ElementKind::Text => "text", ElementKind::Title => "title", ElementKind::Use => "use", } } } /// Represents a SVG element of `kind` type. /// SVG elements can have attributes (a list of [`Attribute`]), and children (a list of [`Element`]). /// Optionally, an SVG element can have `content`. #[derive(Clone, Debug, PartialEq)] pub struct Element { kind: ElementKind, attrs: Vec, children: Vec, content: Option, } impl Element { /// Returns a new SVG element of type `kind`. pub fn new(kind: ElementKind) -> Element { Element { kind, attrs: vec![], children: vec![], content: None, } } /// Adds an attribute `attr` to this element. pub fn add_attr(&mut self, attr: Attribute) { self.attrs.push(attr); } /// Returns an iterator over these element's attributes. pub fn attrs(&self) -> Iter<'_, Attribute> { self.attrs.iter() } /// Adds a `child` to this element. pub fn add_child(&mut self, child: Element) { self.children.push(child); } /// Returns an iterator over these element's children. pub fn children(&self) -> Iter<'_, Element> { self.children.iter() } /// Returns [true] if this element has any child, [false] otherwise. pub fn has_children(&self) -> bool { !self.children.is_empty() } /// Returns this element's kind. pub fn kind(&self) -> ElementKind { self.kind } /// Sets the `content` of this element. pub fn set_content(&mut self, content: &str) { self.content = Some(content.to_string()); } /// Returns [true] if this element has content, [false] otherwise. pub fn has_content(&self) -> bool { self.content.is_some() } /// Returns the content if this element or an empty string if this element has no content. pub fn content(&self) -> &str { match &self.content { None => "", Some(e) => e, } } /// Serializes this element to a SVG string. fn to_svg(&self, buffer: &mut String) { let name = self.kind().name(); buffer.push('<'); buffer.push_str(name); if self.kind() == ElementKind::Svg { // Attributes specific to svg push_attr(buffer, "xmlns", "http://www.w3.org/2000/svg"); } for att in self.attrs() { buffer.push(' '); buffer.push_str(&att.to_string()); } if self.has_children() || self.has_content() { buffer.push('>'); for child in self.children() { child.to_svg(buffer); } buffer.push_str(self.content()); buffer.push_str("'); } else { buffer.push_str(" />"); } } } impl fmt::Display for Element { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let mut text = String::new(); self.to_svg(&mut text); f.write_str(&text) } } fn push_attr(f: &mut String, key: &str, value: &str) { f.push_str(&format!(" {key}=\"{value}\"")); } /// SVG elements can be modified using attributes. /// This list of attributes is __partial__ and only includes attributes necessary for Hurl waterfall /// export. See // TODO: fond a better way to represent unit. For the moment X attribute // take a float but X could be "10", "10px", "10%". #[derive(Clone, Debug, PartialEq)] pub enum Attribute { Class(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/class D(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d DX(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dx DY(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dy Fill(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill Filter(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/filter FloodOpacity(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/flood-opacity FontFamily(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/font-family FontSize(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/font-size FontWeight(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/font-weight Height(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/height Href(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/href Id(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/id Opacity(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/opacity StdDeviation(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stdDeviation Stroke(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke StrokeWidth(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-width TextDecoration(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-decoration ViewBox(f64, f64, f64, f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox Width(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/width X(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/x X1(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/x1 X2(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/x2 Y(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/y Y1(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/y1 Y2(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/y2 } impl Attribute { fn name(&self) -> &'static str { match self { Attribute::Class(_) => "class", Attribute::D(_) => "d", Attribute::DX(_) => "dx", Attribute::DY(_) => "dy", Attribute::Fill(_) => "fill", Attribute::Filter(_) => "filter", Attribute::FloodOpacity(_) => "flood-opacity", Attribute::FontFamily(_) => "font-family", Attribute::FontSize(_) => "font-size", Attribute::FontWeight(_) => "font-weight", Attribute::Height(_) => "height", Attribute::Href(_) => "href", Attribute::Id(_) => "id", Attribute::Opacity(_) => "opacity", Attribute::StdDeviation(_) => "stdDeviation", Attribute::Stroke(_) => "stroke", Attribute::StrokeWidth(_) => "stroke-width", Attribute::TextDecoration(_) => "text-decoration", Attribute::ViewBox(_, _, _, _) => "viewBox", Attribute::Width(_) => "width", Attribute::X(_) => "x", Attribute::X1(_) => "x1", Attribute::X2(_) => "x2", Attribute::Y(_) => "y", Attribute::Y1(_) => "y1", Attribute::Y2(_) => "y2", } } } impl fmt::Display for Attribute { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let value = match self { Attribute::Class(value) => value.clone(), Attribute::D(value) => value.clone(), Attribute::DX(value) => value.to_string(), Attribute::DY(value) => value.to_string(), Attribute::Fill(value) => value.clone(), Attribute::Filter(value) => value.clone(), Attribute::FloodOpacity(value) => value.to_string(), Attribute::FontFamily(value) => value.clone(), Attribute::FontSize(value) => value.clone(), Attribute::FontWeight(value) => value.clone(), Attribute::Height(value) => value.to_string(), Attribute::Href(value) => value.to_string(), Attribute::Id(value) => value.clone(), Attribute::Opacity(value) => value.to_string(), Attribute::StdDeviation(value) => value.to_string(), Attribute::Stroke(value) => value.to_string(), Attribute::StrokeWidth(value) => value.to_string(), Attribute::TextDecoration(value) => value.clone(), Attribute::ViewBox(min_x, min_y, width, height) => { format!("{min_x} {min_y} {width} {height}") } Attribute::Width(value) => value.to_string(), Attribute::X(value) => value.to_string(), Attribute::X1(value) => value.to_string(), Attribute::X2(value) => value.to_string(), Attribute::Y(value) => value.to_string(), Attribute::Y1(value) => value.to_string(), Attribute::Y2(value) => value.to_string(), }; f.write_str(&format!("{}=\"{}\"", self.name(), value)) } } /// Returns a new `` element. pub fn new_a(href: &str) -> Element { let mut elt = Element::new(ElementKind::A); elt.add_attr(Attribute::Href(href.to_string())); elt } /// Returns a new `` element. pub fn new_svg() -> Element { Element::new(ElementKind::Svg) } /// Returns a new `` element. pub fn new_group() -> Element { Element::new(ElementKind::Group) } /// Returns a new `