diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6cad184 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-targets -- -D warnings + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run tests + run: cargo test --all-targets + + build: + name: Build Release + runs-on: ubuntu-latest + needs: [fmt, clippy, test] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build release binary + run: cargo build --release + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: ucp-schema-linux + path: target/release/ucp-schema + + # Optional: Build for multiple platforms on release tags + release-builds: + name: Release Build (${{ matrix.target }}) + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: ucp-schema-linux-x86_64 + - os: macos-latest + target: x86_64-apple-darwin + artifact: ucp-schema-macos-x86_64 + - os: macos-latest + target: aarch64-apple-darwin + artifact: ucp-schema-macos-aarch64 + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact: ucp-schema-windows-x86_64.exe + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + - name: Build + run: cargo build --release --target ${{ matrix.target }} + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: | + target/${{ matrix.target }}/release/ucp-schema + target/${{ matrix.target }}/release/ucp-schema.exe diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f3716f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/local diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d0adbcc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2111 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[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 = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[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 = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[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 = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[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 = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[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 = "fraction" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f158e3ff0a1b334408dc9fb811cd99b446986f4d8b741bb08f9df1604085ae7" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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 = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[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 = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a960f0c34d5423581d858ce94815cc11f0171b09939409097969ed269ede1b" +dependencies = [ + "ahash", + "base64", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "once_cell", + "percent-encoding", + "referencing", + "regex-syntax", + "reqwest", + "serde", + "serde_json", + "uuid-simd", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[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 = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +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 = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8e15af8558cb157432dd3d88c1d1e982d0a5755cf80ce593b6499260aebc49" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "percent-encoding", + "serde_json", +] + +[[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 = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[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 = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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 = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ucp-schema" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "clap", + "jsonschema", + "predicates", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +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.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + +[[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 = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +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 = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +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 = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[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", +] + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..53e1e51 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "ucp-schema" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Runtime resolution of UCP schema annotations" +keywords = ["ucp", "json-schema", "validation"] +categories = ["development-tools", "web-programming"] +rust-version = "1.70" +readme = "README.md" +repository = "https://github.com/Universal-Commerce-Protocol/ucp-schema" + +[lib] +name = "ucp_schema" + +[[bin]] +name = "ucp-schema" +path = "src/bin/ucp-schema.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +thiserror = "2" +clap = { version = "4", features = ["derive"] } +jsonschema = "0.26" + +[dependencies.reqwest] +version = "0.12" +features = ["blocking", "json"] +optional = true + +[features] +default = ["remote"] +remote = ["reqwest"] + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000..b288880 --- /dev/null +++ b/FAQ.md @@ -0,0 +1,140 @@ +# FAQ + +## Are additional fields allowed by default? + +**The validator checks that specced fields are correct, but allows additional fields by design.** + +This is intentional: +- The schema defines minimum requirements, not an exhaustive contract +- Extensions add fields that base schemas don't know about +- Forward compatibility requires tolerating unknown fields +- Clients shouldn't break when servers add new fields + +If a payload has `{ "id": "123", "custom_field": "foo" }` and the schema only defines `id`, validation passes. The `custom_field` is ignored—not rejected. + +Use `--strict=true` only when you need a closed contract: +- Catching field name typos during development +- Systems where the schema is the complete specification + +--- + +## What's the difference between `--schema` and `--schema-base`? + +**They're different validation modes:** + +| Flag | Mode | Schema source | +|------|------|---------------| +| (none) | Self-describing | Fetches URLs from `ucp.capabilities` | +| `--schema-base ./dir` | Self-describing + local | Maps capability URLs to local files | +| `--schema file.json` | Explicit | Uses specified schema, ignores capabilities | + +`--schema-base` is useful for: +- Offline testing +- Local development before publishing schemas +- Testing schema changes against real payloads + +**How it works:** The flag extracts the URL path and maps it to a local file. This works for any domain—not just `ucp.dev`: + +| Schema URL | Local path (`--schema-base ./local`) | +|------------|--------------------------------------| +| `https://ucp.dev/schemas/shopping/checkout.json` | `./local/schemas/shopping/checkout.json` | +| `https://extensions.3p.com/schemas/loyalty.json` | `./local/schemas/loyalty.json` | + +This means you can develop and test third-party extensions locally before publishing. + +--- + +## How does direction auto-detection work? + +**The validator infers direction from payload structure:** + +| Payload has | Detected direction | +|-------------|-------------------| +| `ucp.capabilities` | Response | +| `ucp.meta.profile` | Request | +| Neither | Error (must specify `--request` or `--response`) | + +This only applies to `validate`. The `resolve` command always requires explicit `--request` or `--response`. + +--- + +## Why did validation fail with "unknown visibility"? + +**The validator fails fast on invalid annotations.** Valid values are: `"omit"`, `"required"`, `"optional"`. + +```json +"id": { "ucp_request": "readonly" } // Error: unknown visibility +``` + +Typos and version mismatches should surface immediately, not silently degrade to "include everything" behavior. If you see this error, either fix the typo or update your tooling. + +--- + +## I sent an "omitted" field and validation failed. Why? + +**Omit means "don't send this"—not just "we won't validate it."** + +When a schema has `additionalProperties: false` and a field is omitted: +```json +{ + "additionalProperties": false, + "properties": { + "id": { "ucp_request": "omit" }, + "name": { "type": "string" } + } +} +``` + +Sending `{ "name": "foo", "id": "123" }` for a request fails. The `id` field was removed from `properties`, making it an "additional property" that gets rejected. + +**Why:** If the server generates `id`, clients shouldn't send it. The schema enforces this contract. + +--- + +## How do I write an extension schema? + +**Extensions must define their additions in `$defs[root_capability_name]`.** Composition happens at validation time. Each extension owns its additions but references the base it extends. + +If `dev.ucp.shopping.checkout` is the root capability, your extension schema should look like: + +```json +{ + "$id": "https://ucp.dev/schemas/shopping/discount.json", + "name": "dev.ucp.shopping.discount", + "$defs": { + "dev.ucp.shopping.checkout": { + "allOf": [ + { "$ref": "checkout.json" }, + { + "type": "object", + "properties": { + "discounts": { ... } + } + } + ] + } + } +} +``` + +The validator: +1. Finds the root capability (no `extends`) +2. Extracts `$defs[root_name]` from each extension +3. Composes them with `allOf` + +--- + +## Can extensions remove required fields from the base schema? + +**No. Extensions can tighten requirements, not loosen them.** + +This is JSON Schema semantics. With `allOf`, ALL branches must validate: + +| Base | Extension | Result | +|------|-----------|--------| +| omit | required | required | +| optional | required | required | +| required | omit | **required** (base wins) | +| required | optional | **required** (base wins) | + +If the base schema says `id` is required, clients already depend on it. An extension can't hide it without breaking those clients. Extensions add requirements; they don't remove them. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e75ca9f --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +.PHONY: all build test test-unit test-integration lint fmt fmt-check clean release install smoke-test help + +# Default target +all: fmt-check lint test build + +# Build debug binary +build: + cargo build + +# Build release binary +release: + cargo build --release + +# Run all tests +test: + cargo test + +# Run only unit tests (faster, no CLI invocations) +test-unit: + cargo test --lib + +# Run only integration tests +test-integration: + cargo test --test cli_test + +# Lint with clippy +lint: + cargo clippy -- -D warnings + +# Format code +fmt: + cargo fmt + +# Check formatting (CI-friendly, fails if not formatted) +fmt-check: + cargo fmt -- --check + +# Clean build artifacts +clean: + cargo clean + +# Install to ~/.cargo/bin +install: release + cargo install --path . + +# Quick smoke test with checkout fixture +smoke-test: build + @echo "=== Resolve checkout schema for create ===" + ./target/debug/ucp-schema resolve tests/fixtures/checkout.json --request --op create --pretty + @echo "\n=== Resolve checkout schema for update ===" + ./target/debug/ucp-schema resolve tests/fixtures/checkout.json --request --op update --pretty + +# Show help +help: + @echo "Available targets:" + @echo " all - fmt-check, lint, test, build (default)" + @echo " build - Build debug binary" + @echo " release - Build optimized release binary" + @echo " test - Run all tests" + @echo " test-unit - Run unit tests only" + @echo " test-integration - Run CLI integration tests only" + @echo " lint - Run clippy linter" + @echo " fmt - Format code with rustfmt" + @echo " fmt-check - Check code formatting (CI)" + @echo " clean - Remove build artifacts" + @echo " install - Install release binary to ~/.cargo/bin" + @echo " smoke-test - Quick manual test with checkout fixture" diff --git a/README.md b/README.md new file mode 100644 index 0000000..00e6359 --- /dev/null +++ b/README.md @@ -0,0 +1,452 @@ +# ucp-schema + +CLI and library for working with UCP-annotated JSON Schemas. + +UCP schemas use `ucp_request` and `ucp_response` annotations to define field visibility per operation. This tool resolves those annotations into standard JSON Schema, letting you validate payloads for specific operations (create, read, update, etc.). + +## Installation + +```bash +# Install from crates.io +cargo install ucp-schema + +# Or build from source +git clone https://github.com/Universal-Commerce-Protocol/ucp-schema +cd ucp-schema +cargo install --path . +``` + +## Quick Start + +Given a UCP schema where `id` is omitted on create but required on update: + +```json +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": { "create": "omit", "update": "required" } + }, + "name": { "type": "string" } + } +} +``` + +Resolve it for different operations: + +```bash +# For create: id is removed from the schema +ucp-schema resolve schema.json --request --op create --pretty + +# For update: id is required +ucp-schema resolve schema.json --request --op update --pretty +``` + +Validate a payload: + +```bash +# This fails - id not allowed on create +echo '{"id": "123", "name": "test"}' > payload.json +ucp-schema validate payload.json --schema schema.json --request --op create + +# This passes - id required on update +ucp-schema validate payload.json --schema schema.json --request --op update +``` + +## CLI Reference + +### `resolve` - Generate operation-specific schema + +```bash +ucp-schema resolve --request|--response --op [options] + +Options: + --pretty Pretty-print JSON output + --bundle Inline all external $ref pointers (see Bundling) + --strict=true Reject unknown fields (default: false, see Validation) + --output Write to file instead of stdout +``` + +Examples: + +```bash +# Resolve for create request, pretty print +ucp-schema resolve checkout.json --request --op create --pretty + +# Resolve for read response +ucp-schema resolve checkout.json --response --op read + +# Resolve from URL +ucp-schema resolve https://ucp.dev/schemas/checkout.json --request --op create + +# Save resolved schema to file +ucp-schema resolve checkout.json --request --op create --output resolved.json +``` + +### `validate` - Validate payload against resolved schema + +UCP payloads are self-describing: they embed capability metadata that declares which schemas apply. The validator can use this metadata directly, or you can specify an explicit schema. + +```bash +# Self-describing mode (extracts schema from payload's ucp.capabilities) +ucp-schema validate --op [options] + +# Explicit schema mode (overrides self-describing) +ucp-schema validate --schema --request|--response --op [options] + +Options: + --schema Explicit schema (overrides self-describing mode) + --schema-local-base Local directory to resolve schema URLs (see Validation Modes) + --schema-remote-base URL prefix to strip when mapping to local (see URL Prefix Mapping) + --request Direction is request (required with --schema, auto-detected otherwise) + --response Direction is response (required with --schema, auto-detected otherwise) + --json Output results as JSON (for automation) + --strict=true Reject unknown fields (default: false, see Validation) +``` + +Exit codes: +- `0` - Valid +- `1` - Validation failed (payload doesn't match schema) +- `2` - Schema error (invalid annotations, parse error, composition error) +- `3` - File/network error + +### Validation Modes + +The validator supports three modes based on which flags you provide: + +| Mode | Command | Schema Source | Direction | +|------|---------|---------------|-----------| +| **Self-describing + remote** | `validate payload.json --op read` | `ucp.capabilities` URLs fetched | Auto-detected | +| **Self-describing + local** | `validate payload.json --schema-local-base ./dir --op read` | `ucp.capabilities` URLs mapped to local files | Auto-detected | +| **Explicit schema** | `validate payload.json --schema schema.json --request --op create` | Specified schema file/URL | Must specify `--request` or `--response` | + +**Mode 1: Self-describing + remote fetch** + +UCP payloads embed capability metadata declaring which schemas apply. The validator extracts schema URLs and fetches them: + +```bash +# Payload has ucp.capabilities with schema URLs like https://ucp.dev/schemas/... +# Validator fetches schemas from those URLs and composes them +ucp-schema validate response.json --op read +``` + +Requires: payload has `ucp.capabilities` (responses) or `ucp.meta.profile` (requests). +Direction is auto-detected from payload structure. + +**Mode 2: Self-describing + local resolution** + +Same as above, but schema URLs are resolved to local files instead of fetched: + +```bash +# Schema URL https://ucp.dev/schemas/shopping/checkout.json +# Maps to: ./local/schemas/shopping/checkout.json +ucp-schema validate response.json --schema-local-base ./local --op read +``` + +The `--schema-local-base` flag maps URL paths to local files: +- URL: `https://ucp.dev/schemas/shopping/checkout.json` +- Path extracted: `/schemas/shopping/checkout.json` +- Local file: `{schema-local-base}/schemas/shopping/checkout.json` + +**URL Prefix Mapping** + +When schema URLs have versioned prefixes that don't match your local directory structure, use `--schema-remote-base` to strip the prefix: + +```bash +# Schema URL: https://ucp.dev/draft/schemas/shopping/checkout.json +# Local path: ./site/schemas/shopping/checkout.json (no "draft" directory) +ucp-schema validate response.json \ + --schema-local-base ./site \ + --schema-remote-base "https://ucp.dev/draft" \ + --op read +``` + +Mapping with `--schema-remote-base`: +- URL: `https://ucp.dev/draft/schemas/shopping/checkout.json` +- Strip prefix: `https://ucp.dev/draft` → `/schemas/shopping/checkout.json` +- Local file: `{schema-local-base}/schemas/shopping/checkout.json` + +This is useful when published schemas have versioned `$id` URLs but your local files are organized without the version prefix. + +Useful for: offline testing, local development, testing schema changes before deployment. + +**Mode 3: Explicit schema** + +Bypass self-describing metadata entirely by specifying `--schema`: + +```bash +# Ignores any ucp.capabilities in payload, uses specified schema +ucp-schema validate order.json --schema checkout.json --request --op create + +# Works with URLs too +ucp-schema validate order.json --schema https://ucp.dev/schemas/checkout.json --request --op create +``` + +Requires: explicit `--request` or `--response` flag (direction cannot be auto-detected). + +**Error: No schema source** + +If payload has no `ucp.capabilities`/`ucp.meta.profile` AND no `--schema` is specified: + +```bash +ucp-schema validate payload.json --op read +# Error: payload is not self-describing: missing ucp.capabilities and ucp.meta.profile +``` + +**JSON output for automation:** + +```bash +ucp-schema validate order.json --schema checkout.json --request --op create --json +# Output: {"valid":true} +# Or: {"valid":false,"errors":[{"path":"","message":"..."}]} +``` + +### `lint` - Static analysis of schema files + +Catch schema errors before runtime. The linter checks for issues that would cause failures during resolution or validation. + +```bash +ucp-schema lint [options] + +Options: + --format Output format (default: text) + --strict Treat warnings as errors + --quiet, -q Only show errors, suppress progress +``` + +**What it checks:** + +| Category | Issue | Severity | +|----------|-------|----------| +| Syntax | Invalid JSON | Error | +| References | `$ref` to missing file | Error | +| References | `$ref` to missing anchor (e.g., `#/$defs/foo`) | Error | +| Annotations | Invalid `ucp_*` type (must be string or object) | Error | +| Annotations | Invalid visibility value (must be omit/required/optional) | Error | +| Hygiene | Missing `$id` field | Warning | +| Hygiene | Unknown operation in annotation (e.g., `{"delete": "omit"}`) | Warning | + +**Examples:** + +```bash +# Lint a directory of schemas +ucp-schema lint schemas/ + +# Lint single file, fail on warnings +ucp-schema lint checkout.json --strict + +# CI-friendly JSON output +ucp-schema lint schemas/ --format json + +# Quiet mode - only show errors +ucp-schema lint schemas/ --quiet +``` + +**Exit codes:** +- `0` - All files passed (or only warnings in non-strict mode) +- `1` - Errors found (or warnings in strict mode) +- `2` - Path not found + +**JSON output format:** + +```json +{ + "path": "schemas/", + "files_checked": 5, + "passed": 4, + "failed": 1, + "errors": 1, + "warnings": 2, + "results": [ + { + "file": "checkout.json", + "status": "error", + "diagnostics": [ + { + "severity": "error", + "code": "E002", + "path": "/properties/buyer/$ref", + "message": "file not found: types/buyer.json" + } + ] + } + ] +} +``` + +## Schema Composition from Capabilities + +UCP responses are self-describing - they embed `ucp.capabilities` declaring which schemas apply: + +```json +{ + "ucp": { + "capabilities": { + "dev.ucp.shopping.checkout": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + }], + "dev.ucp.shopping.discount": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + }] + } + }, + "id": "...", + "discounts": { ... } +} +``` + +**How composition works:** + +1. **Root capability**: One capability has no `extends` - this is the base schema +2. **Extensions**: Capabilities with `extends` add fields to the root +3. **Composition**: Extensions define their additions in `$defs[root_capability_name]` +4. **allOf merge**: The composed schema uses `allOf` to combine all extensions + +For the example above, the composed schema is: + +```json +{ + "allOf": [ + { /* checkout's $defs["dev.ucp.shopping.checkout"] from discount.json */ } + ] +} +``` + +**Schema authoring for extensions:** + +Extension schemas must define their additions in `$defs` under the root capability name: + +```json +{ + "$id": "https://ucp.dev/schemas/shopping/discount.json", + "name": "dev.ucp.shopping.discount", + "$defs": { + "dev.ucp.shopping.checkout": { + "allOf": [ + { "$ref": "checkout.json" }, + { + "type": "object", + "properties": { + "discounts": { /* discount-specific fields */ } + } + } + ] + } + } +} +``` + +**Graph validation:** + +- Exactly one root capability (no `extends`) +- All `extends` references must exist in capabilities +- All extensions must transitively reach the root (no orphan extensions) + +## Bundling External References + +UCP schemas often use `$ref` to reference external files: + +```json +{ + "properties": { + "buyer": { "$ref": "types/buyer.json" }, + "shipping": { "$ref": "types/address.json#/$defs/postal" } + } +} +``` + +The `--bundle` flag inlines all external references, producing a self-contained schema: + +```bash +ucp-schema resolve checkout.json --request --op create --bundle --pretty +``` + +**When to use bundling:** +- Distributing schemas without file dependencies +- Feeding schemas to tools that don't support external refs +- Debugging to see the fully-expanded schema +- Pre-processing for faster repeated validation + +**How it works:** +- External file refs (`"$ref": "types/buyer.json"`) are loaded and inlined +- Fragment refs (`"$ref": "types/common.json#/$defs/address"`) navigate to the specific definition +- Internal refs within external files (`"$ref": "#/$defs/foo"`) resolve correctly against their source file +- Self-referential recursive types (`"$ref": "#"`) are preserved (can't be inlined) +- Circular references between files are detected and reported as errors + +## Validation + +By default, the validator respects UCP's extensibility model: + +- **Validates:** Payload conforms to spec shape (types, required fields, enums, nested structures) +- **Allows:** Additional/unknown fields (extensibility is intentional) + +```bash +# Validates that known fields are correct, allows extra fields +ucp-schema validate response.json --op read +``` + +This works because UCP schemas use `additionalProperties: true` intentionally - extensions add new fields, and forward compatibility requires tolerating unknown fields. + +**Enabling strict mode:** + +For cases where you want to reject unknown fields (e.g., closed systems, catching typos): + +```bash +# Reject any fields not defined in schema +ucp-schema validate payload.json --schema schema.json --request --op create --strict=true + +# Resolved schema will have additionalProperties: false injected +ucp-schema resolve schema.json --request --op create --strict=true +``` + +**What strict mode does:** +- Adds `additionalProperties: false` to all object schemas (root, nested, in arrays, in definitions) +- Only injects `false` when `additionalProperties` is missing or explicitly `true` +- Preserves custom `additionalProperties` schemas (e.g., `{"type": "string"}`) +- Preserves explicit `additionalProperties: false` + +**Note:** Strict mode does not work well with `allOf` composition (each branch validates independently and rejects properties from other branches). Use default non-strict mode for composed schemas. + +## Visibility Rules + +Annotations control how fields appear in the resolved schema: + +| Value | Effect on Properties | Effect on Required Array | +| --------------- | -------------------- | ------------------------ | +| `"omit"` | Field removed | Field removed | +| `"required"` | Field kept | Field added | +| `"optional"` | Field kept | Field removed | +| (no annotation) | Field kept | Unchanged | + +### Annotation Formats + +**Shorthand** - applies to all operations: +```json +{ "ucp_request": "omit" } +``` + +**Per-operation** - different behavior per operation: +```json +{ "ucp_request": { "create": "omit", "update": "required", "read": "omit" } } +``` + +**Separate request/response:** +```json +{ + "ucp_request": { "create": "omit" }, + "ucp_response": "required" +} +``` + +## More Information + +See **[FAQ.md](./FAQ.md)** for common questions about validator behavior and design decisions + +## License + +Apache-2.0 diff --git a/fixtures/invalid/invalid-checkout-create.json b/fixtures/invalid/invalid-checkout-create.json new file mode 100644 index 0000000..ae31e92 --- /dev/null +++ b/fixtures/invalid/invalid-checkout-create.json @@ -0,0 +1,5 @@ +{ + "buyer": { + "email": "test@example.com" + } +} diff --git a/fixtures/invalid/invalid-discount-response.json b/fixtures/invalid/invalid-discount-response.json new file mode 100644 index 0000000..debd7f6 --- /dev/null +++ b/fixtures/invalid/invalid-discount-response.json @@ -0,0 +1,37 @@ +{ + "ucp": { + "version": "2026-01-11", + "capabilities": { + "dev.ucp.shopping.checkout": [{"version": "2026-01-11"}], + "dev.ucp.shopping.discount": [{"version": "2026-01-11"}] + }, + "payment_handlers": { + "com.stripe": [{"id": "stripe_gpay", "version": "2026-01-11"}] + } + }, + "id": "checkout_abc123", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_1", + "item": {"id": "prod_123", "title": "Test Product", "price": 1999}, + "quantity": 2, + "totals": [{"type": "subtotal", "amount": 3998}] + } + ], + "totals": [ + {"type": "subtotal", "amount": 3998}, + {"type": "total", "amount": 3498} + ], + "discounts": { + "codes": ["SAVE20"], + "applied": [ + { + "code": "SAVE20", + "title": "20% Off Summer Sale", + "amount": 500 + } + ] + } +} diff --git a/fixtures/invalid/multi-extension-response.json b/fixtures/invalid/multi-extension-response.json new file mode 100644 index 0000000..4014c8a --- /dev/null +++ b/fixtures/invalid/multi-extension-response.json @@ -0,0 +1,19 @@ +{ + "ucp": { + "version": "2026-01-11", + "capabilities": { + "dev.ucp.shopping.checkout": [{"version": "2026-01-11"}], + "dev.ucp.shopping.discount": [{"version": "2026-01-11", "extends": "dev.ucp.shopping.checkout"}], + "dev.ucp.shopping.fulfillment": [{"version": "2026-01-11", "extends": "dev.ucp.shopping.checkout"}] + }, + "payment_handlers": { + "com.stripe": [{"id": "stripe_gpay", "version": "2026-01-11"}] + } + }, + "id": "checkout_abc123", + "status": "incomplete", + "currency": "USD", + "line_items": [], + "totals": [], + "links": [] +} diff --git a/fixtures/valid/discount-checkout-create.json b/fixtures/valid/discount-checkout-create.json new file mode 100644 index 0000000..e802b7a --- /dev/null +++ b/fixtures/valid/discount-checkout-create.json @@ -0,0 +1,13 @@ +{ + "line_items": [ + { + "item": { + "id": "prod_123" + }, + "quantity": 2 + } + ], + "discounts": { + "codes": ["SAVE20"] + } +} diff --git a/fixtures/valid/external-extension-response.json b/fixtures/valid/external-extension-response.json new file mode 100644 index 0000000..988b8f2 --- /dev/null +++ b/fixtures/valid/external-extension-response.json @@ -0,0 +1,35 @@ +{ + "ucp": { + "version": "2026-01-11", + "capabilities": { + "dev.ucp.shopping.checkout": [{"version": "2026-01-11"}], + "dev.shopify.loyalty": [{"version": "2026-01-11", "extends": "dev.ucp.shopping.checkout"}] + }, + "payment_handlers": { + "com.stripe": [{"id": "stripe_gpay", "version": "2026-01-11"}] + } + }, + "id": "checkout_abc123", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_1", + "item": {"id": "prod_123", "title": "Test Product", "price": 1999}, + "quantity": 2, + "totals": [{"type": "subtotal", "amount": 3998}] + } + ], + "totals": [ + {"type": "subtotal", "amount": 3998}, + {"type": "total", "amount": 3998} + ], + "links": [ + {"type": "privacy_policy", "url": "https://example.com/privacy"}, + {"type": "terms_of_service", "url": "https://example.com/terms"} + ], + "loyalty": { + "points_earned": 40, + "tier": "gold" + } +} diff --git a/fixtures/valid/valid-checkout-create.json b/fixtures/valid/valid-checkout-create.json new file mode 100644 index 0000000..bea283d --- /dev/null +++ b/fixtures/valid/valid-checkout-create.json @@ -0,0 +1,18 @@ +{ + "line_items": [ + { + "item": { + "id": "prod_123", + "title": "Test Product", + "price": { + "amount": 1999, + "currency": "USD" + } + }, + "quantity": 2 + } + ], + "buyer": { + "email": "test@example.com" + } +} diff --git a/fixtures/valid/valid-checkout-response.json b/fixtures/valid/valid-checkout-response.json new file mode 100644 index 0000000..331ca02 --- /dev/null +++ b/fixtures/valid/valid-checkout-response.json @@ -0,0 +1,60 @@ +{ + "ucp": { + "version": "2026-01-11", + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": "2026-01-11" + } + ] + }, + "payment_handlers": { + "com.stripe": [ + { + "id": "stripe_gpay", + "version": "2026-01-11" + } + ] + } + }, + "id": "checkout_abc123", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "prod_123", + "title": "Test Product", + "price": 1999 + }, + "quantity": 2, + "totals": [ + { + "type": "subtotal", + "amount": 3998 + } + ] + } + ], + "totals": [ + { + "type": "subtotal", + "amount": 3998 + }, + { + "type": "total", + "amount": 3998 + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://example.com/privacy" + }, + { + "type": "terms_of_service", + "url": "https://example.com/terms" + } + ] +} diff --git a/fixtures/valid/valid-discount-response.json b/fixtures/valid/valid-discount-response.json new file mode 100644 index 0000000..3e3e0a8 --- /dev/null +++ b/fixtures/valid/valid-discount-response.json @@ -0,0 +1,42 @@ +{ + "ucp": { + "version": "2026-01-11", + "capabilities": { + "dev.ucp.shopping.checkout": [{"version": "2026-01-11"}], + "dev.ucp.shopping.discount": [{"version": "2026-01-11", "extends": "dev.ucp.shopping.checkout"}] + }, + "payment_handlers": { + "com.stripe": [{"id": "stripe_gpay", "version": "2026-01-11"}] + } + }, + "id": "checkout_abc123", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_1", + "item": {"id": "prod_123", "title": "Test Product", "price": 1999}, + "quantity": 2, + "totals": [{"type": "subtotal", "amount": 3998}] + } + ], + "totals": [ + {"type": "subtotal", "amount": 3998}, + {"type": "discount", "amount": 500}, + {"type": "total", "amount": 3498} + ], + "links": [ + {"type": "privacy_policy", "url": "https://example.com/privacy"}, + {"type": "terms_of_service", "url": "https://example.com/terms"} + ], + "discounts": { + "codes": ["SAVE20"], + "applied": [ + { + "code": "SAVE20", + "title": "20% Off Summer Sale", + "amount": 500 + } + ] + } +} diff --git a/fixtures/valid/valid-fulfillment-response.json b/fixtures/valid/valid-fulfillment-response.json new file mode 100644 index 0000000..4ed6128 --- /dev/null +++ b/fixtures/valid/valid-fulfillment-response.json @@ -0,0 +1,44 @@ +{ + "ucp": { + "version": "2026-01-11", + "capabilities": { + "dev.ucp.shopping.checkout": [{"version": "2026-01-11"}], + "dev.ucp.shopping.fulfillment": [{"version": "2026-01-11", "extends": "dev.ucp.shopping.checkout"}] + }, + "payment_handlers": { + "com.stripe": [{"id": "stripe_gpay", "version": "2026-01-11"}] + } + }, + "id": "checkout_abc123", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_1", + "item": {"id": "prod_123", "title": "Test Product", "price": 1999}, + "quantity": 2, + "totals": [{"type": "subtotal", "amount": 3998}] + } + ], + "totals": [ + {"type": "subtotal", "amount": 3998}, + {"type": "fulfillment", "amount": 599, "display_text": "Standard Shipping"}, + {"type": "total", "amount": 4597} + ], + "links": [ + {"type": "privacy_policy", "url": "https://example.com/privacy"}, + {"type": "terms_of_service", "url": "https://example.com/terms"} + ], + "fulfillment": { + "type": "shipping", + "options": [ + { + "id": "standard", + "title": "Standard Shipping", + "amount": 599, + "estimated_days": {"min": 5, "max": 7} + } + ], + "selected": "standard" + } +} diff --git a/src/bin/ucp-schema.rs b/src/bin/ucp-schema.rs new file mode 100644 index 0000000..7e734a4 --- /dev/null +++ b/src/bin/ucp-schema.rs @@ -0,0 +1,413 @@ +//! UCP Schema CLI +//! +//! Command-line interface for resolving and validating UCP schemas. + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use ucp_schema::{ + bundle_refs, compose_from_payload, detect_direction, lint, load_schema, load_schema_auto, + resolve, validate, DetectedDirection, Direction, FileStatus, ResolveOptions, SchemaBaseConfig, + ValidateError, +}; + +#[derive(Parser)] +#[command(name = "ucp-schema")] +#[command(about = "Resolve and validate UCP schema annotations")] +#[command(version)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Resolve a schema for a specific direction and operation + Resolve { + /// Schema source: file path or URL (http:// or https://) + schema: String, + + /// Resolve for request direction + #[arg( + long, + conflicts_with = "response", + required_unless_present = "response" + )] + request: bool, + + /// Resolve for response direction + #[arg(long, conflicts_with = "request", required_unless_present = "request")] + response: bool, + + /// Operation to resolve for (e.g., create, update, read) + #[arg(long, short)] + op: String, + + /// Output file (stdout if not specified) + #[arg(long)] + output: Option, + + /// Pretty-print JSON output + #[arg(long)] + pretty: bool, + + /// Dereference all $ref pointers (bundle into single schema) + #[arg(long)] + bundle: bool, + + /// Strict mode: set additionalProperties=false to reject unknown fields (default: false) + #[arg(long, default_value_t = false, action = clap::ArgAction::Set)] + strict: bool, + }, + + /// Validate a payload against a resolved schema + Validate { + /// Payload file to validate + payload: PathBuf, + + /// Explicit schema (default: infer from payload's UCP metadata) + #[arg(long)] + schema: Option, + + /// Local directory containing schema files + #[arg(long)] + schema_local_base: Option, + + /// URL prefix to strip when mapping to local (e.g., https://ucp.dev/draft) + #[arg(long, requires = "schema_local_base")] + schema_remote_base: Option, + + /// Validate as request (auto-inferred if omitted) + #[arg(long, conflicts_with = "response")] + request: bool, + + /// Validate as response (auto-inferred if omitted) + #[arg(long, conflicts_with = "request")] + response: bool, + + /// Operation to validate for (e.g., create, update, read) + #[arg(long, short)] + op: String, + + /// Output results as JSON (for automation) + #[arg(long)] + json: bool, + + /// Strict mode: reject unknown fields (default: false) + #[arg(long, default_value_t = false, action = clap::ArgAction::Set)] + strict: bool, + }, + + /// Lint schema files for errors (syntax, broken refs, invalid annotations) + Lint { + /// File or directory to lint + path: PathBuf, + + /// Output format: text (default) or json + #[arg(long, default_value = "text")] + format: String, + + /// Treat warnings as errors + #[arg(long)] + strict: bool, + + /// Suppress progress output, only show errors + #[arg(long, short)] + quiet: bool, + }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Resolve { + schema, + request, + response: _, + op, + output, + pretty, + bundle, + strict, + } => run_resolve(&schema, request, op, output, pretty, bundle, strict), + + Commands::Validate { + payload, + schema, + schema_local_base, + schema_remote_base, + request, + response, + op, + json, + strict, + } => run_validate(ValidateArgs { + payload, + schema, + schema_local_base, + schema_remote_base, + request, + response, + op, + json_output: json, + strict, + }), + + Commands::Lint { + path, + format, + strict, + quiet, + } => run_lint(&path, &format, strict, quiet), + }; + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(code) => ExitCode::from(code), + } +} + +fn run_resolve( + schema_source: &str, + request: bool, + op: String, + output: Option, + pretty: bool, + bundle: bool, + strict: bool, +) -> Result<(), u8> { + let direction = Direction::from_request_flag(request); + + let mut schema = load_schema_auto(schema_source).map_err(|e| { + eprintln!("Error: {}", e); + e.exit_code() as u8 + })?; + + // Bundle: dereference all $refs before resolving annotations + if bundle { + // Resolve external file refs and their internal refs using our loader + // Note: $ref: "#" (self-refs) are left as-is since they're recursive + let base_dir = std::path::Path::new(schema_source) + .parent() + .unwrap_or(std::path::Path::new(".")); + bundle_refs(&mut schema, base_dir).map_err(|e| { + eprintln!("Error bundling refs: {}", e); + e.exit_code() as u8 + })?; + } + + let options = ResolveOptions::new(direction, op).strict(strict); + let resolved = resolve(&schema, &options).map_err(|e| { + eprintln!("Error: {}", e); + e.exit_code() as u8 + })?; + + let json_output = if pretty { + serde_json::to_string_pretty(&resolved) + } else { + serde_json::to_string(&resolved) + } + .map_err(|e| { + eprintln!("Error serializing output: {}", e); + 2u8 + })?; + + match output { + Some(path) => { + std::fs::write(&path, &json_output).map_err(|e| { + eprintln!("Error writing to {}: {}", path.display(), e); + 3u8 + })?; + } + None => { + println!("{}", json_output); + } + } + + Ok(()) +} + +struct ValidateArgs { + payload: PathBuf, + schema: Option, + schema_local_base: Option, + schema_remote_base: Option, + request: bool, + response: bool, + op: String, + json_output: bool, + strict: bool, +} + +fn run_validate(args: ValidateArgs) -> Result<(), u8> { + let ValidateArgs { + payload: payload_path, + schema: schema_source, + schema_local_base, + schema_remote_base, + request, + response, + op, + json_output, + strict, + } = args; + // Load payload first - needed for both explicit and self-describing modes + let payload = load_schema(&payload_path).map_err(|e| { + report_error(json_output, &format!("loading payload: {}", e)); + e.exit_code() as u8 + })?; + + // Determine direction: explicit flag > auto-inference from payload + let direction = if request { + Direction::Request + } else if response { + Direction::Response + } else { + // Auto-infer from payload structure + match detect_direction(&payload) { + Some(DetectedDirection::Response) => Direction::Response, + Some(DetectedDirection::Request) => Direction::Request, + None => { + // No UCP metadata found - require explicit flag + report_error(json_output, "cannot infer direction: payload has no ucp.capabilities or ucp.meta.profile. Use --request or --response."); + return Err(2); + } + } + }; + + // Get schema: explicit --schema or compose from payload metadata + let schema = match &schema_source { + Some(source) => { + // Explicit schema - load directly + load_schema_auto(source).map_err(|e| { + report_error(json_output, &format!("loading schema: {}", e)); + e.exit_code() as u8 + })? + } + None => { + // Self-describing mode - compose from payload's UCP metadata + let config = SchemaBaseConfig { + local_base: schema_local_base.as_deref(), + remote_base: schema_remote_base.as_deref(), + }; + compose_from_payload(&payload, &config).map_err(|e| { + report_error(json_output, &e.to_string()); + e.exit_code() as u8 + })? + } + }; + + let options = ResolveOptions::new(direction, op).strict(strict); + + match validate(&schema, &payload, &options) { + Ok(()) => { + if json_output { + println!(r#"{{"valid":true}}"#); + } else { + println!("Valid"); + } + Ok(()) + } + Err(ValidateError::Invalid { errors, .. }) => { + if json_output { + let output = serde_json::json!({ + "valid": false, + "errors": errors + }); + println!("{}", output); + } else { + eprintln!("Validation failed:"); + for error in errors { + eprintln!(" {}", error); + } + } + Err(1) + } + Err(ValidateError::Resolve(e)) => { + report_error(json_output, &e.to_string()); + Err(e.exit_code() as u8) + } + } +} + +/// Output an error message in plain text or JSON format. +fn report_error(json_output: bool, msg: &str) { + if json_output { + println!(r#"{{"valid":false,"error":"{}"}}"#, msg); + } else { + eprintln!("Error: {}", msg); + } +} + +fn run_lint(path: &Path, format: &str, strict: bool, quiet: bool) -> Result<(), u8> { + use ucp_schema::Severity; + + if !path.exists() { + eprintln!("Error: path not found: {}", path.display()); + return Err(2); + } + + let result = lint(path, strict); + + if format == "json" { + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } else { + // Text output + if !quiet { + println!("Linting {} ...\n", path.display()); + } + + for file_result in &result.results { + let status_icon = match file_result.status { + FileStatus::Ok => "\x1b[32m✓\x1b[0m", + FileStatus::Warning => "\x1b[33m⚠\x1b[0m", + FileStatus::Error => "\x1b[31m✗\x1b[0m", + }; + + if !quiet || file_result.status != FileStatus::Ok { + println!(" {} {}", status_icon, file_result.file.display()); + } + + for diag in &file_result.diagnostics { + let color = match diag.severity { + Severity::Error => "\x1b[31m", + Severity::Warning => "\x1b[33m", + }; + if !quiet || diag.severity == Severity::Error { + println!( + " {}{}[{}]\x1b[0m: {} - {}", + color, + match diag.severity { + Severity::Error => "error", + Severity::Warning => "warning", + }, + diag.code, + diag.path, + diag.message + ); + } + } + } + + println!(); + if result.is_ok() && (!strict || result.warnings == 0) { + println!( + "\x1b[32m✓ {} files checked, all passed\x1b[0m", + result.files_checked + ); + } else { + println!( + "\x1b[31m✗ {} files checked: {} passed, {} failed ({} errors, {} warnings)\x1b[0m", + result.files_checked, result.passed, result.failed, result.errors, result.warnings + ); + } + } + + if result.is_ok() && (!strict || result.warnings == 0) { + Ok(()) + } else { + Err(1) + } +} diff --git a/src/compose.rs b/src/compose.rs new file mode 100644 index 0000000..cd234af --- /dev/null +++ b/src/compose.rs @@ -0,0 +1,906 @@ +//! Schema composition from UCP capability metadata. +//! +//! UCP payloads are self-describing: they embed capability metadata that declares +//! which schemas apply. This module extracts that metadata and composes the +//! appropriate schema for validation. +//! +//! # Response Pattern +//! +//! Responses have `ucp.capabilities` inline: +//! ```json +//! { +//! "ucp": { +//! "capabilities": { +//! "dev.ucp.shopping.checkout": [{ "version": "...", "schema": "..." }] +//! } +//! } +//! } +//! ``` +//! +//! # Request Pattern +//! +//! Requests reference a profile URL: +//! ```json +//! { +//! "ucp": { +//! "meta": { +//! "profile": "https://agent.example.com/.well-known/ucp" +//! } +//! } +//! } +//! ``` + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::error::ComposeError; +use crate::loader::{bundle_refs, bundle_refs_with_url_mapping, is_url, load_schema}; + +#[cfg(feature = "remote")] +use crate::loader::load_schema_url; + +/// Configuration for mapping schema URLs to local paths. +/// +/// When both `local_base` and `remote_base` are set, URLs starting with +/// `remote_base` have that prefix stripped before joining with `local_base`. +/// +/// Example: +/// - `remote_base`: `https://ucp.dev/draft` +/// - `local_base`: `source` +/// - URL: `https://ucp.dev/draft/schemas/checkout.json` +/// - Result: `source/schemas/checkout.json` +#[derive(Debug, Clone, Default)] +pub struct SchemaBaseConfig<'a> { + /// Local directory containing schema files. + pub local_base: Option<&'a Path>, + /// URL prefix to strip when mapping to local paths. + pub remote_base: Option<&'a str>, +} + +/// Capability declaration extracted from UCP metadata. +#[derive(Debug, Clone)] +pub struct Capability { + /// Reverse-domain capability name (e.g., "dev.ucp.shopping.checkout"). + pub name: String, + /// Version string (e.g., "2026-01-11"). + pub version: String, + /// URL to the JSON Schema for this capability. + pub schema_url: String, + /// Parent capability names this extends. None for root capabilities. + pub extends: Option>, +} + +/// Detected payload direction based on UCP metadata structure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetectedDirection { + /// Payload has `ucp.capabilities` inline (response pattern). + Response, + /// Payload has `ucp.meta.profile` URL (request pattern). + Request, +} + +/// Detect direction from payload structure. +/// +/// Returns `Some(Response)` if `ucp.capabilities` exists, +/// `Some(Request)` if `ucp.meta.profile` exists, +/// `None` if neither is present. +pub fn detect_direction(payload: &Value) -> Option { + let ucp = payload.get("ucp")?; + + if ucp.get("capabilities").is_some() { + return Some(DetectedDirection::Response); + } + + if ucp.get("meta").and_then(|m| m.get("profile")).is_some() { + return Some(DetectedDirection::Request); + } + + None +} + +/// Extract capabilities from a self-describing payload. +/// +/// - Response: extracts from `ucp.capabilities` directly +/// - Request: fetches `ucp.meta.profile` URL, extracts from profile +/// +/// # Arguments +/// * `payload` - The UCP payload to extract capabilities from +/// * `schema_base` - Configuration for mapping schema URLs to local paths +pub fn extract_capabilities( + payload: &Value, + schema_base: &SchemaBaseConfig, +) -> Result, ComposeError> { + let ucp = payload.get("ucp").ok_or(ComposeError::NotSelfDescribing)?; + + // Try response pattern first: ucp.capabilities + if let Some(caps) = ucp.get("capabilities") { + return parse_capabilities_object(caps); + } + + // Try request pattern: ucp.meta.profile + if let Some(profile_url) = ucp + .get("meta") + .and_then(|m| m.get("profile")) + .and_then(|p| p.as_str()) + { + let profile = fetch_profile(profile_url, schema_base)?; + let caps = profile + .get("ucp") + .and_then(|u| u.get("capabilities")) + .ok_or_else(|| ComposeError::ProfileFetch { + url: profile_url.to_string(), + message: "profile missing ucp.capabilities".to_string(), + })?; + return parse_capabilities_object(caps); + } + + Err(ComposeError::NotSelfDescribing) +} + +/// Parse a capabilities object into a list of Capability structs. +fn parse_capabilities_object(caps: &Value) -> Result, ComposeError> { + let obj = caps.as_object().ok_or(ComposeError::EmptyCapabilities)?; + + if obj.is_empty() { + return Err(ComposeError::EmptyCapabilities); + } + + let mut capabilities = Vec::new(); + + for (name, versions) in obj { + // Each capability is an array of version entries + let entries = versions + .as_array() + .ok_or_else(|| ComposeError::InvalidCapability { + name: name.clone(), + message: "expected array of capability entries".to_string(), + })?; + + // Take the first entry (version negotiation already happened) + let entry = entries + .first() + .ok_or_else(|| ComposeError::InvalidCapability { + name: name.clone(), + message: "empty capability array".to_string(), + })?; + + let version = entry + .get("version") + .and_then(|v| v.as_str()) + .ok_or_else(|| ComposeError::InvalidCapability { + name: name.clone(), + message: "missing version field".to_string(), + })? + .to_string(); + + let schema_url = entry + .get("schema") + .and_then(|v| v.as_str()) + .ok_or_else(|| ComposeError::InvalidCapability { + name: name.clone(), + message: "missing schema field".to_string(), + })? + .to_string(); + + // extends can be string or array of strings + let extends = match entry.get("extends") { + None => None, + Some(Value::String(s)) => Some(vec![s.clone()]), + Some(Value::Array(arr)) => { + let parents: Result, _> = arr + .iter() + .map(|v| { + v.as_str().map(|s| s.to_string()).ok_or_else(|| { + ComposeError::InvalidCapability { + name: name.clone(), + message: "extends array must contain strings".to_string(), + } + }) + }) + .collect(); + Some(parents?) + } + Some(_) => { + return Err(ComposeError::InvalidCapability { + name: name.clone(), + message: "extends must be string or array of strings".to_string(), + }); + } + }; + + capabilities.push(Capability { + name: name.clone(), + version, + schema_url, + extends, + }); + } + + Ok(capabilities) +} + +/// Fetch a profile from a URL or local path. +fn fetch_profile(url: &str, schema_base: &SchemaBaseConfig) -> Result { + resolve_schema_url(url, schema_base).map_err(|e| ComposeError::ProfileFetch { + url: url.to_string(), + message: e.to_string(), + }) +} + +/// Compose schema from capability declarations. +/// +/// 1. Finds root capability (no extends) +/// 2. Validates graph connectivity +/// 3. Fetches schemas and extracts $defs[root] entries +/// 4. Composes using allOf +pub fn compose_schema( + capabilities: &[Capability], + schema_base: &SchemaBaseConfig, +) -> Result { + if capabilities.is_empty() { + return Err(ComposeError::EmptyCapabilities); + } + + // Build name -> capability map for lookups + let cap_map: HashMap<&str, &Capability> = + capabilities.iter().map(|c| (c.name.as_str(), c)).collect(); + + // Find root capability (no extends) + let roots: Vec<&Capability> = capabilities + .iter() + .filter(|c| c.extends.is_none()) + .collect(); + + let root = match roots.len() { + 0 => return Err(ComposeError::NoRootCapability), + 1 => roots[0], + _ => { + return Err(ComposeError::MultipleRootCapabilities { + names: roots.iter().map(|c| c.name.clone()).collect(), + }) + } + }; + + // Validate graph: all extends references must exist in capabilities + for cap in capabilities { + if let Some(parents) = &cap.extends { + for parent in parents { + if !cap_map.contains_key(parent.as_str()) { + return Err(ComposeError::UnknownParent { + extension: cap.name.clone(), + parent: parent.clone(), + }); + } + } + } + } + + // Validate graph connectivity: all extensions must reach root + for cap in capabilities { + if cap.extends.is_some() && !reaches_root(cap, &cap_map, &root.name) { + return Err(ComposeError::OrphanExtension { + extension: cap.name.clone(), + root: root.name.clone(), + }); + } + } + + // Get extensions (all non-root capabilities) + let extensions: Vec<&Capability> = capabilities + .iter() + .filter(|c| c.extends.is_some()) + .collect(); + + // If no extensions, just return the root schema + if extensions.is_empty() { + return resolve_schema_url(&root.schema_url, schema_base).map_err(|e| { + ComposeError::SchemaFetch { + url: root.schema_url.clone(), + message: e.to_string(), + } + }); + } + + // Compose: for each extension, extract $defs[root.name] + let mut all_of_schemas = Vec::new(); + + for ext in &extensions { + let ext_schema = resolve_schema_url(&ext.schema_url, schema_base).map_err(|e| { + ComposeError::SchemaFetch { + url: ext.schema_url.clone(), + message: e.to_string(), + } + })?; + + // Extract $defs[root.name] and inline any internal refs + let defs = ext_schema + .get("$defs") + .ok_or_else(|| ComposeError::MissingDefEntry { + extension: ext.name.clone(), + expected_key: root.name.clone(), + })?; + + let ext_def = defs + .get(&root.name) + .ok_or_else(|| ComposeError::MissingDefEntry { + extension: ext.name.clone(), + expected_key: root.name.clone(), + })?; + + // Inline internal #/$defs/... refs so the extracted def is self-contained + let mut inlined = ext_def.clone(); + inline_internal_refs(&mut inlined, defs); + + all_of_schemas.push(inlined); + } + + // Compose into single schema with allOf + Ok(json!({ "allOf": all_of_schemas })) +} + +/// Inline internal `#/$defs/...` refs from the parent schema. +/// +/// When extracting a single definition from a schema, that definition may have +/// internal refs to other definitions in the same schema. This function +/// recursively inlines those refs so the extracted definition is self-contained. +/// +/// # Arguments +/// * `value` - The value to process (modified in place) +/// * `defs` - The `$defs` object to resolve refs against +fn inline_internal_refs(value: &mut Value, defs: &Value) { + inline_internal_refs_inner(value, defs, &mut HashSet::new()); +} + +fn inline_internal_refs_inner(value: &mut Value, defs: &Value, visited: &mut HashSet) { + match value { + Value::Object(obj) => { + // Check if this object has an internal $ref + if let Some(ref_val) = obj.get("$ref").and_then(|v| v.as_str()) { + // Only handle internal refs to $defs (not self-root "#" refs) + if let Some(def_name) = ref_val.strip_prefix("#/$defs/") { + // Guard against circular refs + if visited.contains(def_name) { + return; + } + + // Look up the definition + if let Some(def) = defs.get(def_name) { + visited.insert(def_name.to_string()); + + // Clone and recursively inline + let mut inlined = def.clone(); + inline_internal_refs_inner(&mut inlined, defs, visited); + + visited.remove(def_name); + + // Replace the $ref object with the inlined definition + obj.remove("$ref"); + if let Value::Object(def_obj) = inlined { + for (k, v) in def_obj { + obj.entry(k).or_insert(v); + } + } + return; + } + } + } + + // Recurse into all values + for v in obj.values_mut() { + inline_internal_refs_inner(v, defs, visited); + } + } + Value::Array(arr) => { + for item in arr { + inline_internal_refs_inner(item, defs, visited); + } + } + _ => {} + } +} + +/// Check if a capability transitively reaches the root via extends chain. +fn reaches_root(cap: &Capability, cap_map: &HashMap<&str, &Capability>, root_name: &str) -> bool { + let mut visited = HashSet::new(); + let mut queue = vec![cap]; + + while let Some(current) = queue.pop() { + if visited.contains(¤t.name.as_str()) { + continue; + } + visited.insert(current.name.as_str()); + + if let Some(parents) = ¤t.extends { + for parent_name in parents { + if parent_name == root_name { + return true; + } + if let Some(parent) = cap_map.get(parent_name.as_str()) { + queue.push(parent); + } + } + } + } + + false +} + +/// Convenience: extract capabilities and compose schema in one call. +pub fn compose_from_payload( + payload: &Value, + schema_base: &SchemaBaseConfig, +) -> Result { + let capabilities = extract_capabilities(payload, schema_base)?; + compose_schema(&capabilities, schema_base) +} + +/// Resolve a schema URL to a Value, bundling any $ref pointers. +/// +/// If `schema_base.local_base` is provided, maps URL paths to local files. +/// If `schema_base.remote_base` is also provided, strips that prefix from URLs +/// before mapping (enables versioned URL to unversioned local path mapping). +/// Otherwise, fetches via HTTP. +/// +/// After loading, bundles external $ref pointers so the schema is self-contained. +/// This is necessary because extension schemas often have relative refs like +/// `$ref: "checkout.json"` that need resolution before composition. +fn resolve_schema_url(url: &str, schema_base: &SchemaBaseConfig) -> Result { + if let Some(base) = schema_base.local_base { + // Map URL to local path + let path = if let Some(remote_base) = schema_base.remote_base { + // Strip remote_base prefix if URL starts with it + if let Some(remainder) = url.strip_prefix(remote_base) { + // remainder is like "/schemas/checkout.json" + remainder.to_string() + } else { + // URL doesn't match remote_base, fall back to extracting path + extract_url_path(url)? + } + } else { + // No remote_base, extract path portion of URL + extract_url_path(url)? + }; + + let local_path = base.join(path.trim_start_matches('/')); + let mut schema = load_schema(&local_path).map_err(|_| ComposeError::SchemaFetch { + url: url.to_string(), + message: format!("file not found: {}", local_path.display()), + })?; + + // Bundle refs - use URL-aware version if remote mapping is configured + let schema_dir = local_path.parent().unwrap_or(base); + if let Some(remote_base) = schema_base.remote_base { + // URL mapping configured - internal refs may also be absolute URLs + bundle_refs_with_url_mapping(&mut schema, schema_dir, base, remote_base).map_err( + |e| ComposeError::SchemaFetch { + url: url.to_string(), + message: format!("bundling refs: {}", e), + }, + )?; + } else { + // No URL mapping - use simple relative path bundling + bundle_refs(&mut schema, schema_dir).map_err(|e| ComposeError::SchemaFetch { + url: url.to_string(), + message: format!("bundling refs: {}", e), + })?; + } + + Ok(schema) + } else if is_url(url) { + // HTTP fetch - bundling not supported for remote-only schemas + #[cfg(feature = "remote")] + { + load_schema_url(url).map_err(|e| ComposeError::SchemaFetch { + url: url.to_string(), + message: e.to_string(), + }) + } + #[cfg(not(feature = "remote"))] + { + Err(ComposeError::SchemaFetch { + url: url.to_string(), + message: "HTTP fetching requires 'remote' feature".to_string(), + }) + } + } else { + // Treat as local file path + let local_path = Path::new(url); + let mut schema = load_schema(local_path).map_err(|e| ComposeError::SchemaFetch { + url: url.to_string(), + message: e.to_string(), + })?; + + // Bundle refs using the schema's directory as base + if let Some(schema_dir) = local_path.parent() { + bundle_refs(&mut schema, schema_dir).map_err(|e| ComposeError::SchemaFetch { + url: url.to_string(), + message: format!("bundling refs: {}", e), + })?; + } + + Ok(schema) + } +} + +/// Extract the path portion from a URL. +/// +/// E.g., "https://ucp.dev/schemas/shopping/checkout.json" -> "/schemas/shopping/checkout.json" +fn extract_url_path(url: &str) -> Result { + // Try stripping http:// or https:// prefix + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")); + + match rest { + Some(after_scheme) => { + // URL with scheme - extract path after host + after_scheme + .find('/') + .map(|idx| after_scheme[idx..].to_string()) + .ok_or_else(|| ComposeError::InvalidUrl { + url: url.to_string(), + message: "could not extract path from URL".to_string(), + }) + } + None => { + // Not a URL, treat the whole thing as a path + Ok(url.to_string()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn detect_direction_response() { + let payload = json!({ + "ucp": { + "capabilities": { + "dev.ucp.shopping.checkout": [{"version": "2026-01-11", "schema": "..."}] + } + } + }); + assert_eq!( + detect_direction(&payload), + Some(DetectedDirection::Response) + ); + } + + #[test] + fn detect_direction_request() { + let payload = json!({ + "ucp": { + "meta": { + "profile": "https://example.com/.well-known/ucp" + } + } + }); + assert_eq!(detect_direction(&payload), Some(DetectedDirection::Request)); + } + + #[test] + fn detect_direction_neither() { + let payload = json!({ + "ucp": { + "version": "2026-01-11" + } + }); + assert_eq!(detect_direction(&payload), None); + } + + #[test] + fn detect_direction_no_ucp() { + let payload = json!({ + "id": "123", + "status": "incomplete" + }); + assert_eq!(detect_direction(&payload), None); + } + + #[test] + fn parse_capabilities_single_root() { + let caps = json!({ + "dev.ucp.shopping.checkout": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + }] + }); + let result = parse_capabilities_object(&caps).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "dev.ucp.shopping.checkout"); + assert_eq!(result[0].version, "2026-01-11"); + assert!(result[0].extends.is_none()); + } + + #[test] + fn parse_capabilities_with_extension() { + let caps = json!({ + "dev.ucp.shopping.checkout": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + }], + "dev.ucp.shopping.discount": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + }] + }); + let result = parse_capabilities_object(&caps).unwrap(); + assert_eq!(result.len(), 2); + + let discount = result + .iter() + .find(|c| c.name == "dev.ucp.shopping.discount") + .unwrap(); + assert_eq!( + discount.extends, + Some(vec!["dev.ucp.shopping.checkout".to_string()]) + ); + } + + #[test] + fn parse_capabilities_multi_parent() { + // Tests diamond pattern: combo extends both discount and fulfillment + let caps = json!({ + "dev.ucp.shopping.checkout": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + }], + "dev.ucp.shopping.discount": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + }], + "dev.ucp.shopping.fulfillment": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + }], + "dev.ucp.shopping.combo": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/combo.json", + "extends": ["dev.ucp.shopping.discount", "dev.ucp.shopping.fulfillment"] + }] + }); + let result = parse_capabilities_object(&caps).unwrap(); + assert_eq!(result.len(), 4); + + let combo = result + .iter() + .find(|c| c.name == "dev.ucp.shopping.combo") + .unwrap(); + assert_eq!( + combo.extends, + Some(vec![ + "dev.ucp.shopping.discount".to_string(), + "dev.ucp.shopping.fulfillment".to_string() + ]) + ); + } + + #[test] + fn parse_capabilities_empty() { + let caps = json!({}); + let result = parse_capabilities_object(&caps); + assert!(matches!(result, Err(ComposeError::EmptyCapabilities))); + } + + #[test] + fn extract_url_path_https() { + let path = extract_url_path("https://ucp.dev/schemas/shopping/checkout.json").unwrap(); + assert_eq!(path, "/schemas/shopping/checkout.json"); + } + + #[test] + fn extract_url_path_http() { + let path = extract_url_path("http://localhost:8080/schemas/test.json").unwrap(); + assert_eq!(path, "/schemas/test.json"); + } + + #[test] + fn extract_url_path_local() { + let path = extract_url_path("./schemas/checkout.json").unwrap(); + assert_eq!(path, "./schemas/checkout.json"); + } + + #[test] + fn compose_no_extensions() { + // Setup: single root capability + let checkout = Capability { + name: "dev.ucp.shopping.checkout".to_string(), + version: "2026-01-11".to_string(), + schema_url: "checkout.json".to_string(), + extends: None, + }; + + // This will fail because checkout.json doesn't exist, but tests the logic path + let config = SchemaBaseConfig { + local_base: Some(Path::new("/nonexistent")), + remote_base: None, + }; + let result = compose_schema(&[checkout], &config); + assert!(matches!(result, Err(ComposeError::SchemaFetch { .. }))); + } + + #[test] + fn compose_no_root_error() { + let discount = Capability { + name: "dev.ucp.shopping.discount".to_string(), + version: "2026-01-11".to_string(), + schema_url: "discount.json".to_string(), + extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]), + }; + + let config = SchemaBaseConfig::default(); + let result = compose_schema(&[discount], &config); + assert!(matches!(result, Err(ComposeError::NoRootCapability))); + } + + #[test] + fn compose_multiple_roots_error() { + // Error case: fulfillment is missing its "extends" field, creating two roots + let checkout = Capability { + name: "dev.ucp.shopping.checkout".to_string(), + version: "2026-01-11".to_string(), + schema_url: "checkout.json".to_string(), + extends: None, + }; + let fulfillment = Capability { + name: "dev.ucp.shopping.fulfillment".to_string(), + version: "2026-01-11".to_string(), + schema_url: "fulfillment.json".to_string(), + extends: None, // Bug: should extend checkout + }; + + let config = SchemaBaseConfig::default(); + let result = compose_schema(&[checkout, fulfillment], &config); + assert!(matches!( + result, + Err(ComposeError::MultipleRootCapabilities { .. }) + )); + } + + #[test] + fn compose_unknown_parent_error() { + let checkout = Capability { + name: "dev.ucp.shopping.checkout".to_string(), + version: "2026-01-11".to_string(), + schema_url: "checkout.json".to_string(), + extends: None, + }; + let discount = Capability { + name: "dev.ucp.shopping.discount".to_string(), + version: "2026-01-11".to_string(), + schema_url: "discount.json".to_string(), + extends: Some(vec!["dev.ucp.shopping.nonexistent".to_string()]), + }; + + let config = SchemaBaseConfig::default(); + let result = compose_schema(&[checkout, discount], &config); + assert!(matches!(result, Err(ComposeError::UnknownParent { .. }))); + } + + #[test] + fn reaches_root_direct() { + let checkout = Capability { + name: "dev.ucp.shopping.checkout".to_string(), + version: "2026-01-11".to_string(), + schema_url: "checkout.json".to_string(), + extends: None, + }; + let discount = Capability { + name: "dev.ucp.shopping.discount".to_string(), + version: "2026-01-11".to_string(), + schema_url: "discount.json".to_string(), + extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]), + }; + + let cap_map: HashMap<&str, &Capability> = vec![ + ("dev.ucp.shopping.checkout", &checkout), + ("dev.ucp.shopping.discount", &discount), + ] + .into_iter() + .collect(); + + assert!(reaches_root( + &discount, + &cap_map, + "dev.ucp.shopping.checkout" + )); + } + + #[test] + fn reaches_root_transitive_diamond() { + // Tests diamond extension pattern: combo extends both discount and fulfillment, + // both of which extend checkout. This is a realistic UCP scenario. + let checkout = Capability { + name: "dev.ucp.shopping.checkout".to_string(), + version: "2026-01-11".to_string(), + schema_url: "checkout.json".to_string(), + extends: None, + }; + let discount = Capability { + name: "dev.ucp.shopping.discount".to_string(), + version: "2026-01-11".to_string(), + schema_url: "discount.json".to_string(), + extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]), + }; + let fulfillment = Capability { + name: "dev.ucp.shopping.fulfillment".to_string(), + version: "2026-01-11".to_string(), + schema_url: "fulfillment.json".to_string(), + extends: Some(vec!["dev.ucp.shopping.checkout".to_string()]), + }; + // Combo capability that extends both discount and fulfillment + let combo = Capability { + name: "dev.ucp.shopping.combo".to_string(), + version: "2026-01-11".to_string(), + schema_url: "combo.json".to_string(), + extends: Some(vec![ + "dev.ucp.shopping.discount".to_string(), + "dev.ucp.shopping.fulfillment".to_string(), + ]), + }; + + let cap_map: HashMap<&str, &Capability> = vec![ + ("dev.ucp.shopping.checkout", &checkout), + ("dev.ucp.shopping.discount", &discount), + ("dev.ucp.shopping.fulfillment", &fulfillment), + ("dev.ucp.shopping.combo", &combo), + ] + .into_iter() + .collect(); + + // combo -> discount -> checkout (transitive through discount) + // combo -> fulfillment -> checkout (transitive through fulfillment) + assert!(reaches_root(&combo, &cap_map, "dev.ucp.shopping.checkout")); + // Also verify the direct extensions + assert!(reaches_root( + &discount, + &cap_map, + "dev.ucp.shopping.checkout" + )); + assert!(reaches_root( + &fulfillment, + &cap_map, + "dev.ucp.shopping.checkout" + )); + } + + #[test] + fn reaches_root_orphan() { + // Tests orphan detection: an extension that doesn't connect to root + let checkout = Capability { + name: "dev.ucp.shopping.checkout".to_string(), + version: "2026-01-11".to_string(), + schema_url: "checkout.json".to_string(), + extends: None, + }; + let discount = Capability { + name: "dev.ucp.shopping.discount".to_string(), + version: "2026-01-11".to_string(), + schema_url: "discount.json".to_string(), + // Extends something that's not in the map and not root + extends: Some(vec!["dev.ucp.shopping.nonexistent".to_string()]), + }; + + let cap_map: HashMap<&str, &Capability> = vec![ + ("dev.ucp.shopping.checkout", &checkout), + ("dev.ucp.shopping.discount", &discount), + ] + .into_iter() + .collect(); + + // discount extends nonexistent, which doesn't connect to checkout + assert!(!reaches_root( + &discount, + &cap_map, + "dev.ucp.shopping.checkout" + )); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..f72b3fa --- /dev/null +++ b/src/error.rs @@ -0,0 +1,189 @@ +//! Error types for UCP schema resolution and validation. + +use std::path::PathBuf; +use thiserror::Error; + +/// Errors during schema composition from UCP capability metadata. +#[derive(Debug, Error)] +pub enum ComposeError { + #[error("payload is not self-describing: missing ucp.capabilities and ucp.meta.profile")] + NotSelfDescribing, + + #[error("no capabilities declared in ucp.capabilities")] + EmptyCapabilities, + + #[error("no root capability found (all capabilities have 'extends')")] + NoRootCapability, + + #[error("multiple root capabilities found: {}", names.join(", "))] + MultipleRootCapabilities { names: Vec }, + + #[error("extension '{extension}' references unknown parent '{parent}'")] + UnknownParent { extension: String, parent: String }, + + #[error("extension '{extension}' does not connect to root '{root}'")] + OrphanExtension { extension: String, root: String }, + + #[error("extension '{extension}' missing $defs entry for '{expected_key}'")] + MissingDefEntry { + extension: String, + expected_key: String, + }, + + #[error("failed to fetch schema from {url}: {message}")] + SchemaFetch { url: String, message: String }, + + #[error("failed to fetch profile from {url}: {message}")] + ProfileFetch { url: String, message: String }, + + #[error("invalid capability '{name}': {message}")] + InvalidCapability { name: String, message: String }, + + #[error("invalid URL '{url}': {message}")] + InvalidUrl { url: String, message: String }, +} + +impl ComposeError { + /// Returns the exit code for this error type. + pub fn exit_code(&self) -> i32 { + match self { + Self::SchemaFetch { .. } | Self::ProfileFetch { .. } => 3, // IO + _ => 2, // Schema/composition error + } + } +} + +/// Errors during schema resolution. +#[derive(Debug, Error)] +pub enum ResolveError { + // IO errors (exit code 3) + #[error("file not found: {path}")] + FileNotFound { path: PathBuf }, + + #[error("cannot read {path}: {source}")] + ReadError { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[cfg(feature = "remote")] + #[error("failed to fetch {url}: {source}")] + NetworkError { + url: String, + #[source] + source: reqwest::Error, + }, + + // Parse errors (exit code 2) + #[error("invalid JSON: {source}")] + InvalidJson { + #[source] + source: serde_json::Error, + }, + + // Schema errors (exit code 2) + #[error("invalid annotation at {path}: expected string or object, got {actual}")] + InvalidAnnotationType { path: String, actual: String }, + + #[error("unknown visibility \"{value}\" at {path}: expected omit, required, or optional")] + UnknownVisibility { path: String, value: String }, + + #[error("invalid schema: {message}")] + InvalidSchema { message: String }, + + #[error("failed to bundle schema: {message}")] + BundleError { message: String }, +} + +/// Errors during validation. +#[derive(Debug, Error)] +pub enum ValidateError { + #[error(transparent)] + Resolve(#[from] ResolveError), + + #[error("validation failed with {} error(s)", errors.len())] + Invalid { errors: Vec }, +} + +/// Single validation error with path context. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SchemaError { + /// JSON Pointer (RFC 6901) to the invalid field. + pub path: String, + /// Human-readable error message. + pub message: String, +} + +impl std::fmt::Display for SchemaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.path, self.message) + } +} + +impl ResolveError { + /// Returns the exit code for this error type. + pub fn exit_code(&self) -> i32 { + match self { + ResolveError::FileNotFound { .. } | ResolveError::ReadError { .. } => 3, + #[cfg(feature = "remote")] + ResolveError::NetworkError { .. } => 3, + _ => 2, + } + } +} + +impl ValidateError { + /// Returns the exit code for this error type. + pub fn exit_code(&self) -> i32 { + match self { + ValidateError::Resolve(e) => e.exit_code(), + ValidateError::Invalid { .. } => 1, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_error_exit_codes() { + let err = ResolveError::FileNotFound { + path: PathBuf::from("test.json"), + }; + assert_eq!(err.exit_code(), 3); + + let err = ResolveError::InvalidAnnotationType { + path: "/properties/id".into(), + actual: "number".into(), + }; + assert_eq!(err.exit_code(), 2); + + let err = ResolveError::UnknownVisibility { + path: "/properties/id".into(), + value: "readonly".into(), + }; + assert_eq!(err.exit_code(), 2); + } + + #[test] + fn validate_error_exit_codes() { + let err = ValidateError::Invalid { + errors: vec![SchemaError { + path: "/id".into(), + message: "missing required field".into(), + }], + }; + assert_eq!(err.exit_code(), 1); + } + + #[test] + fn schema_error_display() { + let err = SchemaError { + path: "/buyer/email".into(), + message: "expected string, got number".into(), + }; + assert_eq!(err.to_string(), "/buyer/email: expected string, got number"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b5d4c61 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,80 @@ +//! UCP Schema Resolver +//! +//! Runtime resolution of `ucp_request` and `ucp_response` annotations. +//! +//! This library transforms JSON Schemas with UCP annotations into standard JSON Schemas +//! based on direction (request/response) and operation (create, read, update, etc.). +//! +//! # Example +//! +//! ``` +//! use ucp_schema::{resolve, Direction, ResolveOptions}; +//! use serde_json::json; +//! +//! let schema = json!({ +//! "type": "object", +//! "properties": { +//! "id": { +//! "type": "string", +//! "ucp_request": { +//! "create": "omit", +//! "update": "required" +//! } +//! }, +//! "name": { "type": "string" } +//! } +//! }); +//! +//! let options = ResolveOptions::new(Direction::Request, "create"); +//! let resolved = resolve(&schema, &options).unwrap(); +//! +//! // In the resolved schema, "id" is omitted for create requests +//! assert!(resolved["properties"].get("id").is_none()); +//! assert!(resolved["properties"].get("name").is_some()); +//! ``` +//! +//! # Visibility Rules +//! +//! | Visibility | Effect on `properties` | Effect on `required` | +//! |------------|------------------------|----------------------| +//! | `"omit"` | Remove field | Remove from required | +//! | `"required"` | Keep field | Add to required | +//! | `"optional"` | Keep field | Remove from required | +//! | (none) | Keep field | Preserve original | +//! +//! # Annotation Format +//! +//! Annotations can be shorthand (applies to all operations): +//! ```json +//! { "ucp_request": "omit" } +//! ``` +//! +//! Or per-operation: +//! ```json +//! { "ucp_request": { "create": "omit", "update": "required" } } +//! ``` + +mod compose; +mod error; +mod linter; +mod loader; +mod resolver; +mod types; +mod validator; + +pub use compose::{ + compose_from_payload, compose_schema, detect_direction, extract_capabilities, Capability, + DetectedDirection, SchemaBaseConfig, +}; +pub use error::{ComposeError, ResolveError, SchemaError, ValidateError}; +pub use linter::{lint, lint_file, Diagnostic, FileResult, FileStatus, LintResult, Severity}; +pub use loader::{ + bundle_refs, bundle_refs_with_url_mapping, load_schema, load_schema_auto, load_schema_str, + navigate_fragment, +}; +pub use resolver::{resolve, strip_annotations}; +pub use types::{Direction, ResolveOptions, Visibility}; +pub use validator::{validate, validate_against_schema}; + +#[cfg(feature = "remote")] +pub use loader::load_schema_url; diff --git a/src/linter.rs b/src/linter.rs new file mode 100644 index 0000000..f0248df --- /dev/null +++ b/src/linter.rs @@ -0,0 +1,679 @@ +//! Schema linting - static analysis of UCP schema files. +//! +//! Validates schema files for: +//! - JSON syntax errors +//! - Broken $ref references (file not found, anchor not found) +//! - Invalid ucp_* annotation values + +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::Value; + +use crate::loader::{load_schema, navigate_fragment}; +use crate::types::{json_type_name, Visibility, UCP_ANNOTATIONS, VALID_OPERATIONS}; + +/// Severity level for diagnostics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Error, + Warning, +} + +/// A single diagnostic message from linting. +#[derive(Debug, Clone, Serialize)] +pub struct Diagnostic { + pub severity: Severity, + pub code: String, + pub file: PathBuf, + /// JSON path to the issue (e.g., "/properties/id/ucp_request") + pub path: String, + pub message: String, +} + +/// Result of linting a single file. +#[derive(Debug, Clone, Serialize)] +pub struct FileResult { + pub file: PathBuf, + pub status: FileStatus, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +/// Status of a linted file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum FileStatus { + Ok, + Error, + Warning, +} + +/// Result of linting a directory or set of files. +#[derive(Debug, Clone, Serialize)] +pub struct LintResult { + pub path: PathBuf, + pub files_checked: usize, + pub passed: usize, + pub failed: usize, + pub errors: usize, + pub warnings: usize, + pub results: Vec, +} + +impl LintResult { + /// Returns true if all files passed (no errors). + pub fn is_ok(&self) -> bool { + self.errors == 0 + } +} + +/// Lint a file or directory. +/// +/// If path is a directory, recursively finds all .json files. +/// If `strict` is true, warnings are treated as errors. +/// Returns aggregated results for all files. +pub fn lint(path: &Path, strict: bool) -> LintResult { + let files = collect_schema_files(path); + let mut results = Vec::new(); + let mut total_errors = 0; + let mut total_warnings = 0; + + for file in &files { + let file_result = lint_file(file, path); + let file_errors = file_result + .diagnostics + .iter() + .filter(|d| d.severity == Severity::Error) + .count(); + let file_warnings = file_result + .diagnostics + .iter() + .filter(|d| d.severity == Severity::Warning) + .count(); + + total_errors += file_errors; + total_warnings += file_warnings; + results.push(file_result); + } + + let failed = results + .iter() + .filter(|r| { + if strict { + r.status != FileStatus::Ok + } else { + r.status == FileStatus::Error + } + }) + .count(); + + LintResult { + path: path.to_path_buf(), + files_checked: files.len(), + passed: files.len() - failed, + failed, + errors: total_errors, + warnings: total_warnings, + results, + } +} + +/// Lint a single schema file. +pub fn lint_file(file: &Path, base_path: &Path) -> FileResult { + let mut diagnostics = Vec::new(); + + // Try to load the file (checks syntax) + let schema = match load_schema(file) { + Ok(s) => s, + Err(e) => { + diagnostics.push(Diagnostic { + severity: Severity::Error, + code: "E001".to_string(), + file: file.to_path_buf(), + path: "/".to_string(), + message: format!("syntax error: {}", e), + }); + return FileResult { + file: file.strip_prefix(base_path).unwrap_or(file).to_path_buf(), + status: FileStatus::Error, + diagnostics, + }; + } + }; + + // Check $refs + let file_dir = file.parent().unwrap_or(Path::new(".")); + check_refs(&schema, file, file_dir, "", &schema, &mut diagnostics); + + // Check ucp_* annotations + check_annotations(&schema, file, "", &mut diagnostics); + + // Check for missing $id (warning) + if schema.get("$id").is_none() { + diagnostics.push(Diagnostic { + severity: Severity::Warning, + code: "W002".to_string(), + file: file.to_path_buf(), + path: "/".to_string(), + message: "schema missing $id field".to_string(), + }); + } + + let has_errors = diagnostics.iter().any(|d| d.severity == Severity::Error); + let has_warnings = diagnostics.iter().any(|d| d.severity == Severity::Warning); + + let status = if has_errors { + FileStatus::Error + } else if has_warnings { + FileStatus::Warning + } else { + FileStatus::Ok + }; + + FileResult { + file: file.strip_prefix(base_path).unwrap_or(file).to_path_buf(), + status, + diagnostics, + } +} + +/// Recursively check $ref values in a schema. +fn check_refs( + value: &Value, + file: &Path, + file_dir: &Path, + path: &str, + root: &Value, + diagnostics: &mut Vec, +) { + match value { + Value::Object(map) => { + if let Some(Value::String(ref_val)) = map.get("$ref") { + check_single_ref(ref_val, file, file_dir, path, root, diagnostics); + } + + for (key, val) in map { + let child_path = format!("{}/{}", path, key); + check_refs(val, file, file_dir, &child_path, root, diagnostics); + } + } + Value::Array(arr) => { + for (i, item) in arr.iter().enumerate() { + let child_path = format!("{}/{}", path, i); + check_refs(item, file, file_dir, &child_path, root, diagnostics); + } + } + _ => {} + } +} + +/// Check a single $ref value. +fn check_single_ref( + ref_val: &str, + file: &Path, + file_dir: &Path, + path: &str, + root: &Value, + diagnostics: &mut Vec, +) { + // External URLs can't be validated locally - skip silently + if ref_val.starts_with("http://") || ref_val.starts_with("https://") { + return; + } + + if ref_val.starts_with('#') { + // Internal reference - check anchor resolves + if ref_val != "#" && navigate_fragment(root, ref_val).is_err() { + diagnostics.push(Diagnostic { + severity: Severity::Error, + code: "E003".to_string(), + file: file.to_path_buf(), + path: path.to_string(), + message: format!("anchor not found: {}", ref_val), + }); + } + return; + } + + // File reference (possibly with anchor) + let (file_part, fragment) = match ref_val.find('#') { + Some(idx) => (&ref_val[..idx], Some(&ref_val[idx..])), + None => (ref_val, None), + }; + + let ref_path = file_dir.join(file_part); + if !ref_path.exists() { + diagnostics.push(Diagnostic { + severity: Severity::Error, + code: "E002".to_string(), + file: file.to_path_buf(), + path: path.to_string(), + message: format!("file not found: {}", file_part), + }); + return; + } + + // If there's a fragment, check it resolves in the referenced file + if let Some(frag) = fragment { + if frag != "#" { + match load_schema(&ref_path) { + Ok(ref_schema) => { + if navigate_fragment(&ref_schema, frag).is_err() { + diagnostics.push(Diagnostic { + severity: Severity::Error, + code: "E003".to_string(), + file: file.to_path_buf(), + path: path.to_string(), + message: format!("anchor not found in {}: {}", file_part, frag), + }); + } + } + Err(_) => { + // If we can't load the ref'd file, that's already an error + // from a different check, so don't duplicate + } + } + } + } +} + +/// Recursively check ucp_* annotation values. +fn check_annotations(value: &Value, file: &Path, path: &str, diagnostics: &mut Vec) { + if let Value::Object(map) = value { + // Check all UCP annotations + for &annotation_key in UCP_ANNOTATIONS { + if let Some(annotation) = map.get(annotation_key) { + check_annotation_value(annotation, annotation_key, file, path, diagnostics); + } + } + + // Recurse + for (key, val) in map { + let child_path = format!("{}/{}", path, key); + check_annotations(val, file, &child_path, diagnostics); + } + } else if let Value::Array(arr) = value { + for (i, item) in arr.iter().enumerate() { + let child_path = format!("{}/{}", path, i); + check_annotations(item, file, &child_path, diagnostics); + } + } +} + +/// Check a single ucp_* annotation value is valid. +fn check_annotation_value( + annotation: &Value, + key: &str, + file: &Path, + path: &str, + diagnostics: &mut Vec, +) { + let annotation_path = format!("{}/{}", path, key); + + match annotation { + Value::String(s) => { + if Visibility::parse(s).is_none() { + diagnostics.push(Diagnostic { + severity: Severity::Error, + code: "E004".to_string(), + file: file.to_path_buf(), + path: annotation_path, + message: format!( + "invalid {} value \"{}\": expected omit, required, or optional", + key, s + ), + }); + } + } + Value::Object(map) => { + // Object form: { "create": "omit", "update": "required" } + for (op, val) in map { + let op_path = format!("{}/{}", annotation_path, op); + + // Warn on unknown operations + if !VALID_OPERATIONS.contains(&op.as_str()) { + diagnostics.push(Diagnostic { + severity: Severity::Warning, + code: "W003".to_string(), + file: file.to_path_buf(), + path: op_path.clone(), + message: format!( + "unknown operation \"{}\": expected {}", + op, + VALID_OPERATIONS.join(", ") + ), + }); + } + + // Check value is valid + if let Value::String(s) = val { + if Visibility::parse(s).is_none() { + diagnostics.push(Diagnostic { + severity: Severity::Error, + code: "E004".to_string(), + file: file.to_path_buf(), + path: op_path, + message: format!( + "invalid {} value \"{}\": expected omit, required, or optional", + key, s + ), + }); + } + } else { + diagnostics.push(Diagnostic { + severity: Severity::Error, + code: "E005".to_string(), + file: file.to_path_buf(), + path: op_path, + message: format!( + "invalid {} value type: expected string, got {}", + key, + json_type_name(val) + ), + }); + } + } + } + other => { + diagnostics.push(Diagnostic { + severity: Severity::Error, + code: "E005".to_string(), + file: file.to_path_buf(), + path: annotation_path, + message: format!( + "invalid {} type: expected string or object, got {}", + key, + json_type_name(other) + ), + }); + } + } +} + +/// Collect all .json files in a path (file or directory). +fn collect_schema_files(path: &Path) -> Vec { + if path.is_file() { + if path.extension().map(|e| e == "json").unwrap_or(false) { + return vec![path.to_path_buf()]; + } + return vec![]; + } + + let mut files = Vec::new(); + collect_files_recursive(path, &mut files); + files.sort(); + files +} + +fn collect_files_recursive(dir: &Path, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, files); + } else if path.extension().map(|e| e == "json").unwrap_or(false) { + files.push(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::{tempdir, NamedTempFile}; + + #[test] + fn lint_valid_schema() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{ + "$id": "https://example.com/test.json", + "type": "object", + "properties": {{ + "id": {{ "type": "string" }} + }} + }}"# + ) + .unwrap(); + + let result = lint_file(file.path(), file.path().parent().unwrap()); + assert_eq!(result.status, FileStatus::Ok); + assert!(result.diagnostics.is_empty()); + } + + #[test] + fn lint_invalid_json_syntax() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "{{ not valid json }}").unwrap(); + + let result = lint_file(file.path(), file.path().parent().unwrap()); + assert_eq!(result.status, FileStatus::Error); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!(result.diagnostics[0].code, "E001"); + } + + #[test] + fn lint_broken_internal_ref() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r##"{{ + "$id": "https://example.com/test.json", + "type": "object", + "properties": {{ + "data": {{ "$ref": "#/$defs/missing" }} + }} + }}"## + ) + .unwrap(); + + let result = lint_file(file.path(), file.path().parent().unwrap()); + assert_eq!(result.status, FileStatus::Error); + assert!(result.diagnostics.iter().any(|d| d.code == "E003")); + } + + #[test] + fn lint_broken_file_ref() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{ + "$id": "https://example.com/test.json", + "properties": {{ + "data": {{ "$ref": "nonexistent.json" }} + }} + }}"# + ) + .unwrap(); + + let result = lint_file(file.path(), file.path().parent().unwrap()); + assert_eq!(result.status, FileStatus::Error); + assert!(result.diagnostics.iter().any(|d| d.code == "E002")); + } + + #[test] + fn lint_invalid_ucp_request_value() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{ + "$id": "https://example.com/test.json", + "properties": {{ + "id": {{ + "type": "string", + "ucp_request": "invalid_value" + }} + }} + }}"# + ) + .unwrap(); + + let result = lint_file(file.path(), file.path().parent().unwrap()); + assert_eq!(result.status, FileStatus::Error); + assert!(result.diagnostics.iter().any(|d| d.code == "E004")); + } + + #[test] + fn lint_valid_ucp_annotations() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{ + "$id": "https://example.com/test.json", + "properties": {{ + "id": {{ + "type": "string", + "ucp_request": {{ + "create": "omit", + "update": "required" + }}, + "ucp_response": "omit" + }} + }} + }}"# + ) + .unwrap(); + + let result = lint_file(file.path(), file.path().parent().unwrap()); + assert_eq!(result.status, FileStatus::Ok); + assert!(result.diagnostics.is_empty()); + } + + #[test] + fn lint_invalid_ucp_type() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{ + "$id": "https://example.com/test.json", + "properties": {{ + "id": {{ + "type": "string", + "ucp_request": 123 + }} + }} + }}"# + ) + .unwrap(); + + let result = lint_file(file.path(), file.path().parent().unwrap()); + assert_eq!(result.status, FileStatus::Error); + assert!(result.diagnostics.iter().any(|d| d.code == "E005")); + } + + #[test] + fn lint_missing_id_warning() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{ + "type": "object", + "properties": {{}} + }}"# + ) + .unwrap(); + + let result = lint_file(file.path(), file.path().parent().unwrap()); + assert_eq!(result.status, FileStatus::Warning); + assert!(result.diagnostics.iter().any(|d| d.code == "W002")); + } + + #[test] + fn lint_directory() { + let dir = tempdir().unwrap(); + + // Create valid schema + let valid_path = dir.path().join("valid.json"); + std::fs::write( + &valid_path, + r#"{"$id": "https://example.com/valid.json", "type": "object"}"#, + ) + .unwrap(); + + // Create invalid schema + let invalid_path = dir.path().join("invalid.json"); + std::fs::write(&invalid_path, "{ not json }").unwrap(); + + let result = lint(dir.path(), false); + assert_eq!(result.files_checked, 2); + assert_eq!(result.passed, 1); + assert_eq!(result.failed, 1); + assert!(!result.is_ok()); + } + + #[test] + fn lint_strict_mode() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("test.json"); + // Schema with warning only (missing $id) + std::fs::write(&file_path, r#"{"type": "object"}"#).unwrap(); + + // Non-strict: warnings don't cause failure + let result = lint(&file_path, false); + assert_eq!(result.files_checked, 1); + assert_eq!(result.passed, 1); + assert_eq!(result.failed, 0); + + // Strict: warnings cause failure + let result = lint(&file_path, true); + assert_eq!(result.files_checked, 1); + assert_eq!(result.passed, 0); + assert_eq!(result.failed, 1); + } + + #[test] + fn lint_valid_ref_with_anchor() { + let dir = tempdir().unwrap(); + + // Create referenced schema with $defs + let ref_path = dir.path().join("types.json"); + std::fs::write( + &ref_path, + r#"{"$id": "https://example.com/types.json", "$defs": {"thing": {"type": "string"}}}"#, + ) + .unwrap(); + + // Create schema that references it + let main_path = dir.path().join("main.json"); + std::fs::write( + &main_path, + r#"{"$id": "https://example.com/main.json", "properties": {"x": {"$ref": "types.json#/$defs/thing"}}}"#, + ) + .unwrap(); + + let result = lint_file(&main_path, dir.path()); + assert_eq!(result.status, FileStatus::Ok); + } + + #[test] + fn lint_broken_ref_anchor() { + let dir = tempdir().unwrap(); + + // Create referenced schema without the expected $def + let ref_path = dir.path().join("types.json"); + std::fs::write( + &ref_path, + r#"{"$id": "https://example.com/types.json", "$defs": {}}"#, + ) + .unwrap(); + + // Create schema that references missing anchor + let main_path = dir.path().join("main.json"); + std::fs::write( + &main_path, + r#"{"$id": "https://example.com/main.json", "properties": {"x": {"$ref": "types.json#/$defs/missing"}}}"#, + ) + .unwrap(); + + let result = lint_file(&main_path, dir.path()); + assert_eq!(result.status, FileStatus::Error); + assert!(result.diagnostics.iter().any(|d| d.code == "E003")); + } +} diff --git a/src/loader.rs b/src/loader.rs new file mode 100644 index 0000000..d767656 --- /dev/null +++ b/src/loader.rs @@ -0,0 +1,495 @@ +//! Schema loading from various sources. +//! +//! Handles loading schemas from files, strings, and HTTP URLs. + +use std::path::Path; + +use serde_json::Value; + +use crate::error::ResolveError; + +#[cfg(feature = "remote")] +use std::time::Duration; + +/// Default timeout for HTTP requests (10 seconds). +#[cfg(feature = "remote")] +const HTTP_TIMEOUT: Duration = Duration::from_secs(10); + +/// Load a schema from a file path. +/// +/// # Errors +/// +/// Returns `ResolveError::FileNotFound` if the file doesn't exist, +/// or `ResolveError::InvalidJson` if the file isn't valid JSON. +pub fn load_schema(path: &Path) -> Result { + if !path.exists() { + return Err(ResolveError::FileNotFound { + path: path.to_path_buf(), + }); + } + + let content = std::fs::read_to_string(path).map_err(|source| ResolveError::ReadError { + path: path.to_path_buf(), + source, + })?; + + serde_json::from_str(&content).map_err(|source| ResolveError::InvalidJson { source }) +} + +/// Load a schema from a JSON string. +/// +/// # Errors +/// +/// Returns `ResolveError::InvalidJson` if the string isn't valid JSON. +pub fn load_schema_str(content: &str) -> Result { + serde_json::from_str(content).map_err(|source| ResolveError::InvalidJson { source }) +} + +/// Load a schema from an HTTP/HTTPS URL. +/// +/// Requires the `remote` feature (enabled by default). +/// +/// # Errors +/// +/// Returns `ResolveError::NetworkError` if the request fails, +/// or `ResolveError::InvalidJson` if the response isn't valid JSON. +#[cfg(feature = "remote")] +pub fn load_schema_url(url: &str) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|source| ResolveError::NetworkError { + url: url.to_string(), + source, + })?; + + let response = client + .get(url) + .send() + .map_err(|source| ResolveError::NetworkError { + url: url.to_string(), + source, + })?; + + // Check for HTTP errors before parsing + let response = response + .error_for_status() + .map_err(|source| ResolveError::NetworkError { + url: url.to_string(), + source, + })?; + + response + .json() + .map_err(|source| ResolveError::NetworkError { + url: url.to_string(), + source, + }) +} + +/// Check if a string looks like a URL (starts with http:// or https://). +pub fn is_url(s: &str) -> bool { + s.starts_with("http://") || s.starts_with("https://") +} + +/// Navigate a JSON Pointer fragment (e.g., "#/$defs/foo" or "#/properties/bar"). +/// +/// Returns the value at the given JSON Pointer path within the schema. +/// The fragment should start with '#' (e.g., "#/$defs/foo"). +pub fn navigate_fragment(schema: &Value, fragment: &str) -> Result { + // Remove leading # and split by / + let path = fragment.trim_start_matches('#').trim_start_matches('/'); + if path.is_empty() { + return Ok(schema.clone()); + } + + let mut current = schema; + for part in path.split('/') { + // Unescape JSON Pointer encoding (~1 = /, ~0 = ~) + let key = part.replace("~1", "/").replace("~0", "~"); + current = current.get(&key).ok_or_else(|| ResolveError::BundleError { + message: format!("fragment not found: {}", fragment), + })?; + } + Ok(current.clone()) +} + +/// Recursively resolve and inline external $ref pointers. +/// +/// Walks the schema tree, finds `$ref` values pointing to external files, +/// loads them, and replaces the $ref with the loaded content. +/// Internal refs (`#/...`) in the root schema are left for the validator. +/// Internal refs in loaded external files are resolved against that file. +/// Self-root refs (`$ref: "#"`) are left as-is (recursive type definitions). +/// +/// # Arguments +/// * `schema` - The schema to process (modified in place) +/// * `base_dir` - Base directory for resolving relative file paths +pub fn bundle_refs(schema: &mut Value, base_dir: &Path) -> Result<(), ResolveError> { + bundle_refs_inner( + schema, + base_dir, + None, + None, + None, + &mut std::collections::HashSet::new(), + ) +} + +/// Bundle external $ref pointers with URL-to-local-path mapping. +/// +/// Like `bundle_refs`, but handles absolute URL refs by mapping them to local paths. +/// When a ref starts with `remote_base`, that prefix is stripped and the remainder +/// is joined to `local_base` to form the local file path. +/// +/// # Example +/// ```text +/// remote_base = "https://ucp.dev/draft" +/// local_base = Path::new("site") +/// $ref = "https://ucp.dev/draft/schemas/ucp.json" -> "site/schemas/ucp.json" +/// ``` +pub fn bundle_refs_with_url_mapping( + schema: &mut Value, + base_dir: &Path, + local_base: &Path, + remote_base: &str, +) -> Result<(), ResolveError> { + bundle_refs_inner( + schema, + base_dir, + None, + Some(local_base), + Some(remote_base), + &mut std::collections::HashSet::new(), + ) +} + +fn bundle_refs_inner( + schema: &mut Value, + base_dir: &Path, + file_root: Option<&Value>, // Root of external file for resolving internal refs + url_local_base: Option<&Path>, + url_remote_base: Option<&str>, + visited: &mut std::collections::HashSet, +) -> Result<(), ResolveError> { + match schema { + Value::Object(obj) => { + // Check if this object has a $ref + if let Some(ref_val) = obj.get("$ref").and_then(|v| v.as_str()) { + if ref_val.starts_with('#') { + // Internal ref - only resolve if we have a file_root context + // Skip self-root refs ($ref: "#") - these are recursive type defs + if ref_val == "#" { + // Leave as-is - can't inline recursive self-reference + } else if let Some(root) = file_root { + let mut target = navigate_fragment(root, ref_val)?; + // Recursively process (may have nested refs) + bundle_refs_inner( + &mut target, + base_dir, + file_root, + url_local_base, + url_remote_base, + visited, + )?; + // Inline the resolved definition + obj.remove("$ref"); + if let Value::Object(ref_obj) = target { + for (k, v) in ref_obj { + obj.entry(k).or_insert(v); + } + } + return Ok(()); + } + // No file_root = root schema, leave for validator + } else { + // External ref - may be relative path or absolute URL + let (file_part, fragment) = match ref_val.find('#') { + Some(idx) => (&ref_val[..idx], Some(&ref_val[idx..])), + None => (ref_val, None), + }; + + // Resolve ref to local path, handling URL mapping if configured + let ref_path = + resolve_ref_to_path(file_part, base_dir, url_local_base, url_remote_base); + + let canonical = ref_path.canonicalize().unwrap_or(ref_path.clone()); + let visit_key = format!("{}|{}", canonical.display(), fragment.unwrap_or("")); + + if visited.contains(&visit_key) { + return Err(ResolveError::BundleError { + message: format!("circular reference detected: {}", ref_val), + }); + } + + // Load file - this becomes the new file_root for internal refs + let loaded = load_schema(&ref_path)?; + let mut target = if let Some(frag) = fragment { + navigate_fragment(&loaded, frag)? + } else { + loaded.clone() + }; + + visited.insert(visit_key.clone()); + let ref_dir = ref_path.parent().unwrap_or(base_dir); + // Pass loaded file as file_root so internal refs resolve against it + bundle_refs_inner( + &mut target, + ref_dir, + Some(&loaded), + url_local_base, + url_remote_base, + visited, + )?; + visited.remove(&visit_key); + + obj.remove("$ref"); + if let Value::Object(ref_obj) = target { + for (k, v) in ref_obj { + obj.entry(k).or_insert(v); + } + } + return Ok(()); + } + } + + // Recurse into all values + for value in obj.values_mut() { + bundle_refs_inner( + value, + base_dir, + file_root, + url_local_base, + url_remote_base, + visited, + )?; + } + } + Value::Array(arr) => { + for item in arr { + bundle_refs_inner( + item, + base_dir, + file_root, + url_local_base, + url_remote_base, + visited, + )?; + } + } + _ => {} + } + Ok(()) +} + +/// Resolve a $ref value to a local file path. +/// +/// If URL mapping is configured and the ref matches the remote base, +/// strips the prefix and joins to local_base. Otherwise uses base_dir +/// for relative path resolution. +fn resolve_ref_to_path( + ref_val: &str, + base_dir: &Path, + url_local_base: Option<&Path>, + url_remote_base: Option<&str>, +) -> std::path::PathBuf { + // Check if this is an absolute URL that matches our remote base + if let (Some(local_base), Some(remote_base)) = (url_local_base, url_remote_base) { + if let Some(remainder) = ref_val.strip_prefix(remote_base) { + // URL matches remote base - map to local path + return local_base.join(remainder.trim_start_matches('/')); + } + } + + // Default: treat as relative path from base_dir + base_dir.join(ref_val) +} + +/// Load a schema from a file path or URL. +/// +/// Automatically detects whether the source is a URL or file path. +/// URL loading requires the `remote` feature. +/// +/// # Errors +/// +/// Returns appropriate errors based on the source type. +pub fn load_schema_auto(source: &str) -> Result { + if is_url(source) { + #[cfg(feature = "remote")] + { + load_schema_url(source) + } + #[cfg(not(feature = "remote"))] + { + Err(ResolveError::FileNotFound { + path: std::path::PathBuf::from(source), + }) + } + } else { + load_schema(Path::new(source)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn load_schema_valid_file() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, r#"{{"type": "object"}}"#).unwrap(); + + let schema = load_schema(file.path()).unwrap(); + assert_eq!(schema["type"], "object"); + } + + #[test] + fn load_schema_file_not_found() { + let result = load_schema(Path::new("/nonexistent/path.json")); + assert!(matches!(result, Err(ResolveError::FileNotFound { .. }))); + } + + #[test] + fn load_schema_invalid_json() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "not valid json").unwrap(); + + let result = load_schema(file.path()); + assert!(matches!(result, Err(ResolveError::InvalidJson { .. }))); + } + + #[test] + fn load_schema_str_valid() { + let schema = load_schema_str(r#"{"type": "object"}"#).unwrap(); + assert_eq!(schema["type"], "object"); + } + + #[test] + fn load_schema_str_invalid() { + let result = load_schema_str("not json"); + assert!(matches!(result, Err(ResolveError::InvalidJson { .. }))); + } + + #[test] + fn is_url_https() { + assert!(is_url("https://example.com/schema.json")); + } + + #[test] + fn is_url_http() { + assert!(is_url("http://example.com/schema.json")); + } + + #[test] + fn is_url_file_path() { + assert!(!is_url("/path/to/schema.json")); + assert!(!is_url("./schema.json")); + assert!(!is_url("schema.json")); + } + + #[test] + fn load_schema_auto_file() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, r#"{{"type": "string"}}"#).unwrap(); + + let schema = load_schema_auto(file.path().to_str().unwrap()).unwrap(); + assert_eq!(schema["type"], "string"); + } + + #[test] + fn resolve_ref_to_path_with_url_mapping() { + let base_dir = Path::new("/some/dir"); + let local_base = Path::new("/local/schemas"); + let remote_base = "https://ucp.dev/draft"; + + // URL matching remote base gets mapped to local + let path = resolve_ref_to_path( + "https://ucp.dev/draft/schemas/ucp.json", + base_dir, + Some(local_base), + Some(remote_base), + ); + assert_eq!(path, Path::new("/local/schemas/schemas/ucp.json")); + } + + #[test] + fn resolve_ref_to_path_url_not_matching_remote() { + let base_dir = Path::new("/some/dir"); + let local_base = Path::new("/local/schemas"); + let remote_base = "https://ucp.dev/draft"; + + // URL not matching remote base falls back to base_dir join + let path = resolve_ref_to_path( + "https://other.com/schemas/foo.json", + base_dir, + Some(local_base), + Some(remote_base), + ); + assert_eq!( + path, + Path::new("/some/dir/https://other.com/schemas/foo.json") + ); + } + + #[test] + fn resolve_ref_to_path_relative_ref() { + let base_dir = Path::new("/some/dir"); + + // Relative ref without URL mapping + let path = resolve_ref_to_path("types/buyer.json", base_dir, None, None); + assert_eq!(path, Path::new("/some/dir/types/buyer.json")); + } + + #[test] + fn resolve_ref_to_path_strips_leading_slash() { + let base_dir = Path::new("/some/dir"); + let local_base = Path::new("/local"); + let remote_base = "https://ucp.dev/draft"; + + // Stripping remote base leaves "/schemas/..." - leading slash should be trimmed + let path = resolve_ref_to_path( + "https://ucp.dev/draft/schemas/foo.json", + base_dir, + Some(local_base), + Some(remote_base), + ); + assert_eq!(path, Path::new("/local/schemas/foo.json")); + } + + // Remote tests - require network, use httpbin.org for reliable testing + #[cfg(feature = "remote")] + mod remote { + use super::*; + + #[test] + fn load_schema_url_valid() { + // httpbin.org/json returns a well-known JSON response + let result = load_schema_url("https://httpbin.org/json"); + assert!(result.is_ok()); + let schema = result.unwrap(); + // httpbin returns {"slideshow": {...}} + assert!(schema.get("slideshow").is_some()); + } + + #[test] + fn load_schema_url_404() { + let result = load_schema_url("https://httpbin.org/status/404"); + assert!(matches!(result, Err(ResolveError::NetworkError { .. }))); + } + + #[test] + fn load_schema_url_invalid_host() { + let result = + load_schema_url("https://this-domain-does-not-exist-12345.invalid/schema.json"); + assert!(matches!(result, Err(ResolveError::NetworkError { .. }))); + } + + #[test] + fn load_schema_auto_url() { + let result = load_schema_auto("https://httpbin.org/json"); + assert!(result.is_ok()); + } + } +} diff --git a/src/resolver.rs b/src/resolver.rs new file mode 100644 index 0000000..d529550 --- /dev/null +++ b/src/resolver.rs @@ -0,0 +1,640 @@ +//! Schema resolution - transforms UCP annotated schemas into standard JSON Schema. + +use serde_json::{Map, Value}; + +use crate::error::ResolveError; +use crate::types::{json_type_name, Direction, ResolveOptions, Visibility, UCP_ANNOTATIONS}; + +/// Resolve a schema for a specific direction and operation. +/// +/// Returns a standard JSON Schema with UCP annotations removed. +/// When `options.strict` is true, sets `additionalProperties: false` +/// on all object schemas to reject unknown fields. Default is false +/// to respect UCP's extensibility model. +/// +/// # Errors +/// +/// Returns `ResolveError` if the schema contains invalid annotations. +pub fn resolve(schema: &Value, options: &ResolveOptions) -> Result { + let mut resolved = resolve_value(schema, options, "")?; + + if options.strict { + close_additional_properties(&mut resolved); + } + + Ok(resolved) +} + +/// Recursively set `additionalProperties: false` on all object schemas. +/// +/// Only sets the value if `additionalProperties` is missing or explicitly `true`. +/// If a schema has `additionalProperties` set to a custom schema (object), +/// it's left untouched since the author explicitly defined what's allowed. +fn close_additional_properties(value: &mut Value) { + if let Value::Object(map) = value { + // Check if this is an object schema (has "type": "object" or has "properties") + let is_object_schema = map + .get("type") + .and_then(|t| t.as_str()) + .map(|t| t == "object") + .unwrap_or(false) + || map.contains_key("properties"); + + if is_object_schema { + // Only inject false if additionalProperties is missing or true + // Leave custom schemas (objects) alone - author knows what they want + match map.get("additionalProperties") { + None => { + map.insert("additionalProperties".to_string(), Value::Bool(false)); + } + Some(Value::Bool(true)) => { + map.insert("additionalProperties".to_string(), Value::Bool(false)); + } + // false or custom schema - leave as-is + _ => {} + } + } + + // Recurse into all values + for (key, child) in map.iter_mut() { + match key.as_str() { + "properties" => { + // Recurse into each property definition + if let Value::Object(props) = child { + for prop_value in props.values_mut() { + close_additional_properties(prop_value); + } + } + } + "items" | "additionalProperties" => { + // Schema values - recurse + close_additional_properties(child); + } + "$defs" | "definitions" => { + // Definitions - recurse into each + if let Value::Object(defs) = child { + for def_value in defs.values_mut() { + close_additional_properties(def_value); + } + } + } + "allOf" | "anyOf" | "oneOf" => { + // Composition - recurse into each branch + if let Value::Array(arr) = child { + for item in arr { + close_additional_properties(item); + } + } + } + _ => {} + } + } + } +} + +/// Get visibility for a single property. +/// +/// Looks up the appropriate annotation (`ucp_request` or `ucp_response`) and +/// determines the visibility for the given operation. +/// +/// # Errors +/// +/// Returns `ResolveError` if the annotation has invalid type or unknown visibility value. +pub fn get_visibility( + prop: &Value, + direction: Direction, + operation: &str, + path: &str, +) -> Result { + let key = direction.annotation_key(); + let Some(annotation) = prop.get(key) else { + return Ok(Visibility::Include); + }; + + match annotation { + // Shorthand: "ucp_request": "omit" - applies to all operations + Value::String(s) => parse_visibility_string(s, path), + + // Object form: "ucp_request": { "create": "omit", "update": "required" } + Value::Object(map) => { + // Lookup operation (already lowercase from ResolveOptions) + match map.get(operation) { + Some(Value::String(s)) => parse_visibility_string(s, path), + Some(other) => Err(ResolveError::InvalidAnnotationType { + path: format!("{}/{}", path, operation), + actual: json_type_name(other).to_string(), + }), + // Operation not specified → default to include + None => Ok(Visibility::Include), + } + } + + // Invalid type + other => Err(ResolveError::InvalidAnnotationType { + path: path.to_string(), + actual: json_type_name(other).to_string(), + }), + } +} + +/// Strip all UCP annotations from a schema. +/// +/// Recursively removes `ucp_request` and `ucp_response`. +pub fn strip_annotations(schema: &Value) -> Value { + strip_annotations_recursive(schema) +} + +// --- Internal implementation --- + +fn resolve_value( + value: &Value, + options: &ResolveOptions, + path: &str, +) -> Result { + match value { + Value::Object(map) => resolve_object(map, options, path), + Value::Array(arr) => resolve_array(arr, options, path), + // Primitives pass through unchanged + other => Ok(other.clone()), + } +} + +fn resolve_object( + map: &Map, + options: &ResolveOptions, + path: &str, +) -> Result { + let mut result = Map::new(); + + // Track required array modifications + let original_required: Vec = map + .get("required") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let mut new_required: Vec = original_required.clone(); + + for (key, value) in map { + // Skip UCP annotations in output + if UCP_ANNOTATIONS.contains(&key.as_str()) { + continue; + } + + let child_path = format!("{}/{}", path, key); + + match key.as_str() { + "properties" => { + let resolved = resolve_properties(value, options, &child_path, &mut new_required)?; + result.insert(key.clone(), resolved); + } + "items" => { + // Array items - recurse + let resolved = resolve_value(value, options, &child_path)?; + result.insert(key.clone(), resolved); + } + "$defs" | "definitions" => { + // Definitions - recurse into each definition + let resolved = resolve_defs(value, options, &child_path)?; + result.insert(key.clone(), resolved); + } + "allOf" | "anyOf" | "oneOf" => { + // Composition - transform each branch + let resolved = resolve_composition(value, options, &child_path)?; + result.insert(key.clone(), resolved); + } + "additionalProperties" => { + // If it's a schema (object), recurse; otherwise keep as-is + if value.is_object() { + let resolved = resolve_value(value, options, &child_path)?; + result.insert(key.clone(), resolved); + } else { + result.insert(key.clone(), value.clone()); + } + } + "required" => { + // Will be handled at the end after processing properties + continue; + } + _ => { + // Other keys - recurse if object/array, otherwise copy + let resolved = resolve_value(value, options, &child_path)?; + result.insert(key.clone(), resolved); + } + } + } + + // Add updated required array if non-empty or if original existed + if !new_required.is_empty() || map.contains_key("required") { + result.insert( + "required".to_string(), + Value::Array(new_required.into_iter().map(Value::String).collect()), + ); + } + + Ok(Value::Object(result)) +} + +fn resolve_properties( + value: &Value, + options: &ResolveOptions, + path: &str, + required: &mut Vec, +) -> Result { + let Some(props) = value.as_object() else { + return Ok(value.clone()); + }; + + let mut result = Map::new(); + + for (prop_name, prop_value) in props { + let prop_path = format!("{}/{}", path, prop_name); + + // Get visibility for this property + let visibility = get_visibility( + prop_value, + options.direction, + &options.operation, + &prop_path, + )?; + + match visibility { + Visibility::Omit => { + // Remove from properties and required + required.retain(|r| r != prop_name); + } + Visibility::Required => { + // Keep property, ensure in required + let resolved = resolve_value(prop_value, options, &prop_path)?; + let stripped = strip_annotations(&resolved); + result.insert(prop_name.clone(), stripped); + if !required.contains(prop_name) { + required.push(prop_name.clone()); + } + } + Visibility::Optional => { + // Keep property, remove from required + let resolved = resolve_value(prop_value, options, &prop_path)?; + let stripped = strip_annotations(&resolved); + result.insert(prop_name.clone(), stripped); + required.retain(|r| r != prop_name); + } + Visibility::Include => { + // Keep as-is (preserve original required status) + let resolved = resolve_value(prop_value, options, &prop_path)?; + let stripped = strip_annotations(&resolved); + result.insert(prop_name.clone(), stripped); + } + } + } + + Ok(Value::Object(result)) +} + +fn resolve_defs( + value: &Value, + options: &ResolveOptions, + path: &str, +) -> Result { + let Some(defs) = value.as_object() else { + return Ok(value.clone()); + }; + + let mut result = Map::new(); + for (name, def) in defs { + let def_path = format!("{}/{}", path, name); + let resolved = resolve_value(def, options, &def_path)?; + result.insert(name.clone(), resolved); + } + + Ok(Value::Object(result)) +} + +fn resolve_array( + arr: &[Value], + options: &ResolveOptions, + path: &str, +) -> Result { + let mut result = Vec::new(); + for (i, item) in arr.iter().enumerate() { + let item_path = format!("{}/{}", path, i); + let resolved = resolve_value(item, options, &item_path)?; + result.push(resolved); + } + Ok(Value::Array(result)) +} + +fn resolve_composition( + value: &Value, + options: &ResolveOptions, + path: &str, +) -> Result { + let Some(arr) = value.as_array() else { + return Ok(value.clone()); + }; + + let mut result = Vec::new(); + for (i, item) in arr.iter().enumerate() { + let item_path = format!("{}/{}", path, i); + let resolved = resolve_value(item, options, &item_path)?; + result.push(resolved); + } + + Ok(Value::Array(result)) +} + +fn strip_annotations_recursive(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut result = Map::new(); + for (k, v) in map { + if !UCP_ANNOTATIONS.contains(&k.as_str()) { + result.insert(k.clone(), strip_annotations_recursive(v)); + } + } + Value::Object(result) + } + Value::Array(arr) => Value::Array(arr.iter().map(strip_annotations_recursive).collect()), + other => other.clone(), + } +} + +fn parse_visibility_string(s: &str, path: &str) -> Result { + Visibility::parse(s).ok_or_else(|| ResolveError::UnknownVisibility { + path: path.to_string(), + value: s.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // === Visibility Parsing Tests === + + #[test] + fn get_visibility_shorthand_omit() { + let prop = json!({ + "type": "string", + "ucp_request": "omit" + }); + let vis = get_visibility(&prop, Direction::Request, "create", "/test").unwrap(); + assert_eq!(vis, Visibility::Omit); + } + + #[test] + fn get_visibility_shorthand_required() { + let prop = json!({ + "type": "string", + "ucp_request": "required" + }); + let vis = get_visibility(&prop, Direction::Request, "create", "/test").unwrap(); + assert_eq!(vis, Visibility::Required); + } + + #[test] + fn get_visibility_object_form() { + let prop = json!({ + "type": "string", + "ucp_request": { + "create": "omit", + "update": "required" + } + }); + let vis = get_visibility(&prop, Direction::Request, "create", "/test").unwrap(); + assert_eq!(vis, Visibility::Omit); + + let vis = get_visibility(&prop, Direction::Request, "update", "/test").unwrap(); + assert_eq!(vis, Visibility::Required); + } + + #[test] + fn get_visibility_missing_annotation() { + let prop = json!({ + "type": "string" + }); + let vis = get_visibility(&prop, Direction::Request, "create", "/test").unwrap(); + assert_eq!(vis, Visibility::Include); + } + + #[test] + fn get_visibility_missing_operation_in_dict() { + let prop = json!({ + "type": "string", + "ucp_request": { + "create": "omit" + } + }); + // "update" not in dict, should default to include + let vis = get_visibility(&prop, Direction::Request, "update", "/test").unwrap(); + assert_eq!(vis, Visibility::Include); + } + + #[test] + fn get_visibility_response_direction() { + let prop = json!({ + "type": "string", + "ucp_response": "omit" + }); + let vis = get_visibility(&prop, Direction::Response, "create", "/test").unwrap(); + assert_eq!(vis, Visibility::Omit); + + // Request direction should see include (no ucp_request annotation) + let vis = get_visibility(&prop, Direction::Request, "create", "/test").unwrap(); + assert_eq!(vis, Visibility::Include); + } + + #[test] + fn get_visibility_invalid_type_errors() { + let prop = json!({ + "type": "string", + "ucp_request": 123 + }); + let result = get_visibility(&prop, Direction::Request, "create", "/test"); + assert!(matches!( + result, + Err(ResolveError::InvalidAnnotationType { .. }) + )); + } + + #[test] + fn get_visibility_unknown_visibility_errors() { + let prop = json!({ + "type": "string", + "ucp_request": "readonly" + }); + let result = get_visibility(&prop, Direction::Request, "create", "/test"); + assert!(matches!( + result, + Err(ResolveError::UnknownVisibility { value, .. }) if value == "readonly" + )); + } + + #[test] + fn get_visibility_unknown_in_dict_errors() { + let prop = json!({ + "type": "string", + "ucp_request": { + "create": "maybe" + } + }); + let result = get_visibility(&prop, Direction::Request, "create", "/test"); + assert!(matches!( + result, + Err(ResolveError::UnknownVisibility { value, .. }) if value == "maybe" + )); + } + + // === Transformation Tests === + + #[test] + fn resolve_omit_removes_field() { + let schema = json!({ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": "omit" }, + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + assert!(result["properties"].get("id").is_none()); + assert!(result["properties"].get("name").is_some()); + } + + #[test] + fn resolve_omit_removes_from_required() { + let schema = json!({ + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "ucp_request": "omit" }, + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + let required = result["required"].as_array().unwrap(); + assert!(!required.contains(&json!("id"))); + assert!(required.contains(&json!("name"))); + } + + #[test] + fn resolve_required_adds_to_required() { + let schema = json!({ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": "required" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + let required = result["required"].as_array().unwrap(); + assert!(required.contains(&json!("id"))); + } + + #[test] + fn resolve_optional_removes_from_required() { + let schema = json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "ucp_request": "optional" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + let required = result["required"].as_array().unwrap(); + assert!(!required.contains(&json!("id"))); + } + + #[test] + fn resolve_include_preserves_original() { + let schema = json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Both fields should be present + assert!(result["properties"].get("id").is_some()); + assert!(result["properties"].get("name").is_some()); + + // Required should be preserved + let required = result["required"].as_array().unwrap(); + assert!(required.contains(&json!("id"))); + assert!(!required.contains(&json!("name"))); + } + + #[test] + fn resolve_strips_annotations() { + let schema = json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": "required", + "ucp_response": "omit" + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Annotations should be stripped + assert!(result["properties"]["id"].get("ucp_request").is_none()); + assert!(result["properties"]["id"].get("ucp_response").is_none()); + } + + #[test] + fn resolve_empty_schema_after_filtering() { + let schema = json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "ucp_request": "omit" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Properties should be empty object + assert_eq!(result["properties"], json!({})); + // Required should be empty array + assert_eq!(result["required"], json!([])); + } + + // === Strip Annotations Tests === + + #[test] + fn strip_annotations_removes_all_ucp() { + let schema = json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": "omit", + "ucp_response": "required" + } + } + }); + let result = strip_annotations(&schema); + + assert!(result["properties"]["id"].get("ucp_request").is_none()); + assert!(result["properties"]["id"].get("ucp_response").is_none()); + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..7c5ad88 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,149 @@ +//! Core types for UCP schema resolution. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Valid UCP operations for annotation object form. +pub const VALID_OPERATIONS: &[&str] = &["create", "update", "complete", "read"]; + +/// UCP annotation keys. +pub const UCP_ANNOTATIONS: &[&str] = &["ucp_request", "ucp_response"]; + +/// Returns the JSON type name for error messages. +pub fn json_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// Direction of the schema transformation. +/// +/// Determines whether to use `ucp_request` or `ucp_response` annotations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Direction { + Request, + Response, +} + +impl Direction { + /// Returns the annotation key for this direction. + pub fn annotation_key(&self) -> &'static str { + match self { + Direction::Request => "ucp_request", + Direction::Response => "ucp_response", + } + } + + /// Create direction from a request flag (true = Request, false = Response). + pub fn from_request_flag(is_request: bool) -> Self { + if is_request { + Direction::Request + } else { + Direction::Response + } + } +} + +/// Visibility of a field after resolution. +/// +/// Determines how a field is transformed in the output schema. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum Visibility { + /// No transformation - keep field as-is with original required status. + #[default] + Include, + /// Remove field from properties and required array. + Omit, + /// Keep field and ensure it's in the required array. + Required, + /// Keep field but remove from required array. + Optional, +} + +impl Visibility { + /// Parse a visibility value from a string. + /// + /// Returns `None` for unknown values (caller should error). + pub fn parse(s: &str) -> Option { + match s { + "omit" => Some(Visibility::Omit), + "required" => Some(Visibility::Required), + "optional" => Some(Visibility::Optional), + _ => None, + } + } +} + +/// Options for schema resolution. +#[derive(Debug, Clone)] +pub struct ResolveOptions { + /// Whether resolving for request or response. + pub direction: Direction, + /// The operation to resolve for (e.g., "create", "update"). + /// Will be normalized to lowercase. + pub operation: String, + /// When true, sets `additionalProperties: false` on all object schemas + /// to reject unknown fields. Defaults to false to respect schema extensibility. + pub strict: bool, +} + +impl ResolveOptions { + /// Create new resolve options with strict mode disabled (default). + /// + /// Operation is normalized to lowercase for case-insensitive matching. + /// Strict mode is off by default to respect UCP's extensibility model: + /// schemas validate known fields but allow additional properties. + pub fn new(direction: Direction, operation: impl Into) -> Self { + Self { + direction, + operation: operation.into().to_lowercase(), + strict: false, + } + } + + /// Set strict mode (additionalProperties: false on all objects). + pub fn strict(mut self, strict: bool) -> Self { + self.strict = strict; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direction_annotation_key() { + assert_eq!(Direction::Request.annotation_key(), "ucp_request"); + assert_eq!(Direction::Response.annotation_key(), "ucp_response"); + } + + #[test] + fn visibility_parse_valid() { + assert_eq!(Visibility::parse("omit"), Some(Visibility::Omit)); + assert_eq!(Visibility::parse("required"), Some(Visibility::Required)); + assert_eq!(Visibility::parse("optional"), Some(Visibility::Optional)); + } + + #[test] + fn visibility_parse_invalid() { + assert_eq!(Visibility::parse("include"), None); + assert_eq!(Visibility::parse("readonly"), None); + assert_eq!(Visibility::parse(""), None); + } + + #[test] + fn resolve_options_normalizes_operation() { + let opts = ResolveOptions::new(Direction::Request, "Create"); + assert_eq!(opts.operation, "create"); + + let opts = ResolveOptions::new(Direction::Request, "UPDATE"); + assert_eq!(opts.operation, "update"); + } +} diff --git a/src/validator.rs b/src/validator.rs new file mode 100644 index 0000000..622a9c4 --- /dev/null +++ b/src/validator.rs @@ -0,0 +1,147 @@ +//! Payload validation against resolved schemas. + +use serde_json::Value; + +use crate::error::{ResolveError, SchemaError, ValidateError}; +use crate::resolver::resolve; +use crate::types::ResolveOptions; + +/// Validate a payload against a UCP schema. +/// +/// Resolves the schema for the given direction and operation, then validates +/// the payload against the resolved schema. +/// +/// # Errors +/// +/// Returns `ValidateError::Resolve` if schema resolution fails, or +/// `ValidateError::Invalid` if the payload doesn't match the schema. +pub fn validate( + schema: &Value, + payload: &Value, + options: &ResolveOptions, +) -> Result<(), ValidateError> { + // First resolve the schema + let resolved = resolve(schema, options)?; + + // Then validate against resolved schema + validate_against_schema(&resolved, payload) +} + +/// Validate a payload against an already-resolved schema. +/// +/// Use this when you've already resolved the schema and want to validate +/// multiple payloads against it. +pub fn validate_against_schema(schema: &Value, payload: &Value) -> Result<(), ValidateError> { + let validator = jsonschema::validator_for(schema).map_err(|e| { + ValidateError::Resolve(ResolveError::InvalidSchema { + message: e.to_string(), + }) + })?; + + let errors: Vec = validator + .iter_errors(payload) + .map(|e| SchemaError { + path: e.instance_path.to_string(), + message: e.to_string(), + }) + .collect(); + + if errors.is_empty() { + Ok(()) + } else { + Err(ValidateError::Invalid { errors }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Direction; + use serde_json::json; + + #[test] + fn validate_valid_payload() { + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] + }); + let payload = json!({ "name": "test" }); + let options = ResolveOptions::new(Direction::Request, "create"); + + let result = validate(&schema, &payload, &options); + assert!(result.is_ok()); + } + + #[test] + fn validate_missing_required_field() { + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string", "ucp_request": "required" } + } + }); + let payload = json!({}); + let options = ResolveOptions::new(Direction::Request, "create"); + + let result = validate(&schema, &payload, &options); + assert!(matches!(result, Err(ValidateError::Invalid { .. }))); + } + + #[test] + fn validate_wrong_type() { + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + let payload = json!({ "name": 123 }); + let options = ResolveOptions::new(Direction::Request, "create"); + + let result = validate(&schema, &payload, &options); + assert!(matches!(result, Err(ValidateError::Invalid { .. }))); + } + + #[test] + fn validate_omitted_field_rejected() { + // When additionalProperties is false and a field is omitted, + // sending that field should fail validation + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string", "ucp_request": "omit" }, + "name": { "type": "string" } + } + }); + let payload = json!({ "name": "test", "id": "123" }); + let options = ResolveOptions::new(Direction::Request, "create"); + + let result = validate(&schema, &payload, &options); + assert!(matches!(result, Err(ValidateError::Invalid { .. }))); + } + + #[test] + fn validate_collects_multiple_errors() { + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string", "ucp_request": "required" }, + "age": { "type": "number", "ucp_request": "required" } + } + }); + let payload = json!({}); + let options = ResolveOptions::new(Direction::Request, "create"); + + let result = validate(&schema, &payload, &options); + match result { + Err(ValidateError::Invalid { errors }) => { + assert_eq!(errors.len(), 2); + } + _ => panic!("expected validation error with 2 errors"), + } + } +} diff --git a/tests/cli_test.rs b/tests/cli_test.rs new file mode 100644 index 0000000..f7dddb8 --- /dev/null +++ b/tests/cli_test.rs @@ -0,0 +1,1348 @@ +//! CLI integration tests for ucp-schema binary. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use tempfile::TempDir; + +fn cmd() -> Command { + Command::new(assert_cmd::cargo::cargo_bin!("ucp-schema")) +} + +// Helper to create a temp schema file +fn write_temp_file(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.path().join(name); + fs::write(&path, content).unwrap(); + path +} + +mod resolve_command { + use super::*; + + #[test] + fn basic_resolve() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": "required" }, + "name": { "type": "string" } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .success() + .stdout(predicate::str::contains(r#""required":["id"]"#)); + } + + #[test] + fn resolve_with_pretty() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{"type":"object","properties":{"id":{"type":"string"}}}"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + "--pretty", + ]) + .assert() + .success() + // Pretty output has newlines and indentation + .stdout(predicate::str::contains("{\n")); + } + + #[test] + fn resolve_with_output_file() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{"type":"object","properties":{"id":{"type":"string"}}}"#, + ); + let output = dir.path().join("output.json"); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + "--output", + output.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::is_empty()); + + // Verify file was written + let content = fs::read_to_string(&output).unwrap(); + assert!(content.contains(r#""type":"object""#)); + } + + #[test] + fn resolve_strips_annotations() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": "required" } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .success() + // Should not contain UCP annotations in output + .stdout(predicate::str::contains("ucp_request").not()); + } + + #[test] + fn resolve_omits_field() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": "omit" }, + "name": { "type": "string" } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .success() + // Should not contain "id" property in output + .stdout(predicate::str::contains(r#""id""#).not()) + .stdout(predicate::str::contains(r#""name""#)); + } + + #[test] + fn resolve_response_direction() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_response": "required" } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--response", + "--op", + "read", + ]) + .assert() + .success() + .stdout(predicate::str::contains(r#""required":["id"]"#)); + } + + #[test] + fn resolve_operation_case_insensitive() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": { "create": "required", "update": "omit" } + } + } + }"#, + ); + + // Using uppercase CREATE should work and match "create" + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "CREATE", + ]) + .assert() + .success() + .stdout(predicate::str::contains(r#""required":["id"]"#)); + } +} + +mod validate_command { + use super::*; + + #[test] + fn validate_valid_payload() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "name": { "type": "string", "ucp_request": "required" } + } + }"#, + ); + let payload = write_temp_file(&dir, "payload.json", r#"{"name": "test"}"#); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Valid")); + } + + #[test] + fn validate_missing_required_field() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "name": { "type": "string", "ucp_request": "required" } + } + }"#, + ); + let payload = write_temp_file(&dir, "payload.json", r#"{}"#); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .code(1) + .stderr(predicate::str::contains("Validation failed")); + } + + #[test] + fn validate_wrong_type() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "age": { "type": "number" } + } + }"#, + ); + let payload = write_temp_file(&dir, "payload.json", r#"{"age": "not-a-number"}"#); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .code(1) + .stderr(predicate::str::contains("Validation failed")); + } + + #[test] + fn validate_additional_property_rejected() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string", "ucp_request": "omit" }, + "name": { "type": "string" } + } + }"#, + ); + // Try to send "id" which is omitted - should be rejected as additional property + let payload = write_temp_file(&dir, "payload.json", r#"{"name": "test", "id": "123"}"#); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .code(1); + } + + #[test] + fn validate_json_output_valid() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }"#, + ); + let payload = write_temp_file(&dir, "payload.json", r#"{"name": "test"}"#); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + "--json", + ]) + .assert() + .success() + .stdout(predicate::str::contains(r#"{"valid":true}"#)); + } + + #[test] + fn validate_json_output_invalid() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "name": { "type": "string", "ucp_request": "required" } + } + }"#, + ); + let payload = write_temp_file(&dir, "payload.json", r#"{}"#); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + "--json", + ]) + .assert() + .code(1) + .stdout(predicate::str::contains(r#""valid":false"#)) + .stdout(predicate::str::contains(r#""errors":"#)); + } + + #[test] + fn validate_json_output_file_error() { + let dir = TempDir::new().unwrap(); + let payload = write_temp_file(&dir, "payload.json", r#"{}"#); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + "/nonexistent/schema.json", + "--request", + "--op", + "create", + "--json", + ]) + .assert() + .code(3) + .stdout(predicate::str::contains(r#""valid":false"#)) + .stdout(predicate::str::contains(r#""error":"#)); + } +} + +mod error_handling { + use super::*; + + #[test] + fn file_not_found() { + cmd() + .args([ + "resolve", + "/nonexistent/schema.json", + "--request", + "--op", + "create", + ]) + .assert() + .code(3) + .stderr( + predicate::str::contains("not found").or(predicate::str::contains("No such file")), + ); + } + + #[test] + fn invalid_json_schema() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file(&dir, "bad.json", r#"{ not valid json"#); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .code(2); + } + + #[test] + fn invalid_annotation_type() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": 123 } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("annotation")); + } + + #[test] + fn unknown_visibility_value() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": "readonly" } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("unknown visibility")); + } +} + +mod required_args { + use super::*; + + #[test] + fn missing_direction_flag() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file(&dir, "schema.json", r#"{"type":"object"}"#); + + cmd() + .args(["resolve", schema.to_str().unwrap(), "--op", "create"]) + .assert() + .failure() + .stderr( + predicate::str::contains("--request").or(predicate::str::contains("--response")), + ); + } + + #[test] + fn missing_op_flag() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file(&dir, "schema.json", r#"{"type":"object"}"#); + + cmd() + .args(["resolve", schema.to_str().unwrap(), "--request"]) + .assert() + .failure() + .stderr(predicate::str::contains("--op")); + } + + #[test] + fn conflicting_direction_flags() { + let dir = TempDir::new().unwrap(); + let schema = write_temp_file(&dir, "schema.json", r#"{"type":"object"}"#); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--response", + "--op", + "create", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); + } + + #[test] + fn missing_schema_path() { + cmd() + .args(["resolve", "--request", "--op", "create"]) + .assert() + .failure(); + } + + #[test] + fn missing_payload_for_validate() { + // Payload is now required positional argument + cmd() + .args(["validate", "--request", "--op", "create"]) + .assert() + .failure() + .stderr(predicate::str::contains("PAYLOAD")); + } +} + +mod help_and_version { + use super::*; + + #[test] + fn help_flag() { + cmd() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Resolve and validate UCP schema")); + } + + #[test] + fn version_flag() { + cmd() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("ucp-schema")); + } + + #[test] + fn resolve_help() { + cmd() + .args(["resolve", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--request")) + .stdout(predicate::str::contains("--response")) + .stdout(predicate::str::contains("--op")); + } + + #[test] + fn validate_help() { + cmd() + .args(["validate", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--request")) + .stdout(predicate::str::contains("--response")) + .stdout(predicate::str::contains("--op")); + } +} + +mod fixtures { + use super::*; + + #[test] + fn resolve_checkout_fixture_create() { + let fixture = "tests/fixtures/checkout.json"; + + cmd() + .args(["resolve", fixture, "--request", "--op", "create"]) + .assert() + .success() + // line_items is required for create + .stdout(predicate::str::contains("line_items")); + } + + #[test] + fn resolve_checkout_fixture_update() { + let fixture = "tests/fixtures/checkout.json"; + + cmd() + .args(["resolve", fixture, "--request", "--op", "update"]) + .assert() + .success() + // id is required for update + .stdout(predicate::str::contains(r#""required":["id"]"#)); + } + + #[test] + fn validate_checkout_create_valid() { + let dir = TempDir::new().unwrap(); + let payload = write_temp_file( + &dir, + "payload.json", + r#"{ + "line_items": [ + { "sku": "ABC123", "quantity": 2 } + ] + }"#, + ); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + "tests/fixtures/checkout.json", + "--request", + "--op", + "create", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Valid")); + } + + #[test] + fn validate_checkout_create_missing_required() { + let dir = TempDir::new().unwrap(); + // Missing line_items which is required for create + let payload = write_temp_file(&dir, "payload.json", r#"{}"#); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + "tests/fixtures/checkout.json", + "--request", + "--op", + "create", + ]) + .assert() + .code(1) + .stderr(predicate::str::contains("Validation failed")); + } +} + +/// Bundle flag tests - resolve external $refs +mod bundle { + use super::*; + + #[test] + fn bundle_resolves_external_ref() { + let dir = TempDir::new().unwrap(); + + // Create a referenced type schema + fs::create_dir_all(dir.path().join("types")).unwrap(); + fs::write( + dir.path().join("types/buyer.json"), + r#"{"type":"object","properties":{"email":{"type":"string"}}}"#, + ) + .unwrap(); + + // Create main schema with $ref + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "buyer": { "$ref": "types/buyer.json" } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + "--bundle", + ]) + .assert() + .success() + // $ref should be resolved, email property should be present + .stdout(predicate::str::contains(r#""email""#)) + // No $ref should remain (except self-refs) + .stdout(predicate::str::contains(r#""$ref":"types/buyer.json""#).not()); + } + + #[test] + fn bundle_resolves_fragment_ref() { + let dir = TempDir::new().unwrap(); + + // Create schema with $defs + fs::create_dir_all(dir.path().join("types")).unwrap(); + fs::write( + dir.path().join("types/common.json"), + r#"{ + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" } + } + } + } + }"#, + ) + .unwrap(); + + // Reference specific $def with fragment + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "shipping": { "$ref": "types/common.json#/$defs/address" } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + "--bundle", + ]) + .assert() + .success() + // Fragment should be resolved, street property should be present + .stdout(predicate::str::contains(r#""street""#)); + } + + #[test] + fn bundle_preserves_self_root_ref() { + let dir = TempDir::new().unwrap(); + + // Create schema with self-root ref (recursive type) + fs::create_dir_all(dir.path().join("types")).unwrap(); + fs::write( + dir.path().join("types/node.json"), + r##"{ + "type": "object", + "properties": { + "value": { "type": "string" }, + "children": { + "type": "array", + "items": { "$ref": "#" } + } + } + }"##, + ) + .unwrap(); + + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "tree": { "$ref": "types/node.json" } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + "--bundle", + ]) + .assert() + .success() + // Self-root ref should be preserved (can't inline recursive) + .stdout(predicate::str::contains(r##""$ref":"#""##)); + } + + #[test] + fn bundle_resolves_internal_refs_in_external_files() { + let dir = TempDir::new().unwrap(); + + // External file with internal $defs reference + fs::create_dir_all(dir.path().join("types")).unwrap(); + fs::write( + dir.path().join("types/wrapper.json"), + r##"{ + "$defs": { + "inner": { + "type": "string", + "minLength": 1 + } + }, + "type": "object", + "properties": { + "data": { "$ref": "#/$defs/inner" } + } + }"##, + ) + .unwrap(); + + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "wrapped": { "$ref": "types/wrapper.json" } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + "--bundle", + ]) + .assert() + .success() + // Internal ref should be resolved + .stdout(predicate::str::contains(r#""minLength""#)); + } + + #[test] + fn bundle_detects_circular_refs() { + let dir = TempDir::new().unwrap(); + + // Create circular reference: a.json -> b.json -> a.json + fs::create_dir_all(dir.path().join("types")).unwrap(); + fs::write( + dir.path().join("types/a.json"), + r#"{"type":"object","properties":{"b":{"$ref":"b.json"}}}"#, + ) + .unwrap(); + fs::write( + dir.path().join("types/b.json"), + r#"{"type":"object","properties":{"a":{"$ref":"a.json"}}}"#, + ) + .unwrap(); + + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "start": { "$ref": "types/a.json" } + } + }"#, + ); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + "--bundle", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("circular")); + } + + #[test] + fn bundle_output_is_valid_json() { + let dir = TempDir::new().unwrap(); + + fs::create_dir_all(dir.path().join("types")).unwrap(); + fs::write( + dir.path().join("types/item.json"), + r#"{"type":"object","properties":{"id":{"type":"string"}}}"#, + ) + .unwrap(); + + let schema = write_temp_file( + &dir, + "schema.json", + r#"{ + "type": "object", + "properties": { + "item": { "$ref": "types/item.json" } + } + }"#, + ); + + let output = dir.path().join("bundled.json"); + + cmd() + .args([ + "resolve", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + "--bundle", + "--output", + output.to_str().unwrap(), + ]) + .assert() + .success(); + + // Verify output is valid JSON + let content = fs::read_to_string(&output).unwrap(); + let parsed: Result = serde_json::from_str(&content); + assert!(parsed.is_ok(), "Bundle output should be valid JSON"); + } +} + +/// Remote schema loading tests - require network access +mod remote { + use super::*; + + #[test] + fn resolve_from_url() { + // Use httpbin.org which returns valid JSON + // The resolve command should fetch and process it + cmd() + .args([ + "resolve", + "https://httpbin.org/json", + "--request", + "--op", + "create", + ]) + .assert() + .success() + // httpbin returns {"slideshow": {...}} which should pass through + .stdout(predicate::str::contains("slideshow")); + } + + #[test] + fn resolve_url_404() { + cmd() + .args([ + "resolve", + "https://httpbin.org/status/404", + "--request", + "--op", + "create", + ]) + .assert() + .code(3) // Network errors are exit code 3 + .stderr( + predicate::str::contains("failed to fetch").or(predicate::str::contains("404")), + ); + } + + #[test] + fn resolve_url_invalid_host() { + cmd() + .args([ + "resolve", + "https://this-domain-does-not-exist-12345.invalid/schema.json", + "--request", + "--op", + "create", + ]) + .assert() + .code(3); + } + + #[test] + fn validate_with_remote_schema() { + let dir = TempDir::new().unwrap(); + // httpbin.org/json returns {"slideshow": {"author": "...", ...}} + // Create a payload that matches that structure + let payload = write_temp_file( + &dir, + "payload.json", + r#"{"slideshow": {"author": "Test Author", "title": "Test"}}"#, + ); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + "https://httpbin.org/json", + "--request", + "--op", + "create", + ]) + .assert() + .success(); + } +} + +/// Schema composition tests - self-describing payloads +mod compose { + use super::*; + + #[test] + fn self_describing_checkout_only() { + // Validate a self-describing response against local schemas + // Note: --strict=false because strict mode + allOf composition conflict + cmd() + .args([ + "validate", + "tests/fixtures/compose/response_checkout_only.json", + "--schema-local-base", + "tests/fixtures/compose", + "--response", + "--op", + "read", + "--strict=false", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Valid")); + } + + #[test] + fn self_describing_with_extensions() { + // Validate a self-describing response with discount + fulfillment extensions + // Note: --strict=false because strict mode + allOf composition conflict + cmd() + .args([ + "validate", + "tests/fixtures/compose/response_with_extensions.json", + "--schema-local-base", + "tests/fixtures/compose", + "--response", + "--op", + "read", + "--strict=false", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Valid")); + } + + #[test] + fn direction_auto_inferred_response() { + // Direction should be auto-inferred from ucp.capabilities + // Note: --strict=false because strict mode + allOf composition conflict + cmd() + .args([ + "validate", + "tests/fixtures/compose/response_checkout_only.json", + "--schema-local-base", + "tests/fixtures/compose", + "--op", + "read", + "--strict=false", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Valid")); + } + + #[test] + fn schema_remote_base_maps_url_prefix() { + // Test that --schema-remote-base strips URL prefix when mapping to local + // Fixture has schema URL like https://ucp.dev/schemas/shopping/checkout.json + // With remote base, this maps to tests/fixtures/compose/schemas/shopping/checkout.json + let dir = TempDir::new().unwrap(); + let payload = write_temp_file( + &dir, + "payload.json", + r#"{ + "ucp": { + "capabilities": { + "dev.ucp.shopping.checkout": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/versioned/schemas/shopping/checkout.json" + }] + }, + "payment_handlers": {} + }, + "id": "123", + "line_items": [], + "status": "incomplete", + "currency": "USD", + "totals": [], + "links": [] + }"#, + ); + + // Without remote base, would try to extract /versioned/schemas/... path + // With remote base https://ucp.dev/versioned, strips prefix leaving /schemas/... + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema-local-base", + "tests/fixtures/compose", + "--schema-remote-base", + "https://ucp.dev/versioned", + "--response", + "--op", + "read", + "--strict=false", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Valid")); + } + + #[test] + fn schema_remote_base_requires_local_base() { + // --schema-remote-base requires --schema-local-base + cmd() + .args([ + "validate", + "tests/fixtures/compose/response_checkout_only.json", + "--schema-remote-base", + "https://ucp.dev/draft", + "--op", + "read", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("schema-local-base")); + } + + #[test] + fn not_self_describing_requires_schema() { + let dir = TempDir::new().unwrap(); + // Payload without UCP metadata + let payload = write_temp_file(&dir, "payload.json", r#"{"name": "test"}"#); + + cmd() + .args(["validate", payload.to_str().unwrap(), "--op", "create"]) + .assert() + .code(2) + .stderr(predicate::str::contains("cannot infer direction")); + } + + #[test] + fn explicit_schema_overrides_self_describing() { + let dir = TempDir::new().unwrap(); + // Self-describing payload but we override with explicit schema + let schema = write_temp_file( + &dir, + "schema.json", + r#"{"type": "object", "properties": {"custom": {"type": "string"}}}"#, + ); + let payload = write_temp_file(&dir, "payload.json", r#"{"custom": "value"}"#); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema", + schema.to_str().unwrap(), + "--request", + "--op", + "create", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Valid")); + } + + #[test] + fn missing_schema_base_error() { + // Self-describing payload without --schema-local-base, no network (simulated by invalid path) + cmd() + .args([ + "validate", + "tests/fixtures/compose/response_checkout_only.json", + "--schema-local-base", + "/nonexistent/schemas", + "--response", + "--op", + "read", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("failed to fetch schema")); + } + + #[test] + fn empty_capabilities_error() { + let dir = TempDir::new().unwrap(); + let payload = write_temp_file( + &dir, + "payload.json", + r#"{ + "ucp": { + "capabilities": {} + }, + "id": "123" + }"#, + ); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema-local-base", + "tests/fixtures/compose", + "--response", + "--op", + "read", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("no capabilities")); + } + + #[test] + fn unknown_parent_error() { + let dir = TempDir::new().unwrap(); + // Extension references parent not in capabilities (but has a root) + let payload = write_temp_file( + &dir, + "payload.json", + r#"{ + "ucp": { + "capabilities": { + "dev.ucp.shopping.checkout": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + }], + "dev.ucp.shopping.discount": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.nonexistent" + }] + } + } + }"#, + ); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema-local-base", + "tests/fixtures/compose", + "--response", + "--op", + "read", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("unknown parent")); + } + + #[test] + fn json_output_compose_error() { + let dir = TempDir::new().unwrap(); + let payload = write_temp_file( + &dir, + "payload.json", + r#"{ + "ucp": { + "capabilities": {} + } + }"#, + ); + + cmd() + .args([ + "validate", + payload.to_str().unwrap(), + "--schema-local-base", + "tests/fixtures/compose", + "--response", + "--op", + "read", + "--json", + ]) + .assert() + .code(2) + .stdout(predicate::str::contains(r#""valid":false"#)) + .stdout(predicate::str::contains(r#""error":"#)); + } +} diff --git a/tests/fixtures/checkout.json b/tests/fixtures/checkout.json new file mode 100644 index 0000000..fe6c7ab --- /dev/null +++ b/tests/fixtures/checkout.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/checkout.json", + "type": "object", + "required": ["id", "line_items", "status"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Unique identifier", + "ucp_request": { + "create": "omit", + "update": "required", + "read": "required" + } + }, + "line_items": { + "type": "array", + "items": { + "$ref": "#/$defs/line_item" + }, + "ucp_request": { + "create": "required", + "update": "optional", + "read": "omit" + } + }, + "buyer": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "ucp_request": { + "create": "optional", + "update": "optional" + } + }, + "name": { + "type": "string", + "ucp_request": "optional" + } + }, + "ucp_request": { + "create": "optional", + "update": "optional", + "read": "omit" + } + }, + "status": { + "type": "string", + "enum": ["pending", "completed", "cancelled"], + "ucp_request": "omit" + }, + "totals": { + "type": "object", + "properties": { + "subtotal": { "type": "number" }, + "tax": { "type": "number" }, + "total": { "type": "number" } + }, + "ucp_request": "omit" + } + }, + "$defs": { + "line_item": { + "type": "object", + "required": ["sku", "quantity"], + "properties": { + "sku": { + "type": "string", + "ucp_request": "required" + }, + "quantity": { + "type": "integer", + "minimum": 1, + "ucp_request": "required" + }, + "price": { + "type": "number", + "ucp_request": "omit" + } + } + } + } +} diff --git a/tests/fixtures/compose/response_checkout_only.json b/tests/fixtures/compose/response_checkout_only.json new file mode 100644 index 0000000..29997d6 --- /dev/null +++ b/tests/fixtures/compose/response_checkout_only.json @@ -0,0 +1,15 @@ +{ + "ucp": { + "capabilities": { + "dev.ucp.shopping.checkout": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + }] + } + }, + "id": "chk_123", + "status": "incomplete", + "line_items": [ + { "sku": "ABC123", "quantity": 2 } + ] +} diff --git a/tests/fixtures/compose/response_with_extensions.json b/tests/fixtures/compose/response_with_extensions.json new file mode 100644 index 0000000..93ebe45 --- /dev/null +++ b/tests/fixtures/compose/response_with_extensions.json @@ -0,0 +1,39 @@ +{ + "ucp": { + "capabilities": { + "dev.ucp.shopping.checkout": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + }], + "dev.ucp.shopping.discount": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + }], + "dev.ucp.shopping.fulfillment": [{ + "version": "2026-01-11", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + }] + } + }, + "id": "chk_456", + "status": "incomplete", + "line_items": [ + { "sku": "XYZ789", "quantity": 1 } + ], + "discounts": { + "codes": ["SAVE10"], + "applied": [ + { "code": "SAVE10", "title": "10% Off", "amount": 500 } + ] + }, + "fulfillment": { + "method": "shipping", + "address": { + "street": "123 Main St", + "city": "Anytown", + "postal_code": "12345" + } + } +} diff --git a/tests/fixtures/compose/schemas/shopping/checkout.json b/tests/fixtures/compose/schemas/shopping/checkout.json new file mode 100644 index 0000000..edd5bb0 --- /dev/null +++ b/tests/fixtures/compose/schemas/shopping/checkout.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/checkout.json", + "name": "dev.ucp.shopping.checkout", + "version": "2026-01-11", + "title": "Checkout", + "description": "Minimal checkout schema for testing composition.", + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "description": "Checkout ID", + "ucp_request": { + "create": "omit", + "update": "required" + }, + "ucp_response": "required" + }, + "status": { + "type": "string", + "enum": ["incomplete", "complete"], + "ucp_request": "omit", + "ucp_response": "required" + }, + "line_items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sku": { "type": "string" }, + "quantity": { "type": "integer", "minimum": 1 } + }, + "required": ["sku", "quantity"] + }, + "ucp_request": { + "create": "required", + "update": "optional" + } + }, + "ucp": { + "type": "object", + "additionalProperties": true, + "description": "UCP metadata block" + } + } +} diff --git a/tests/fixtures/compose/schemas/shopping/discount.json b/tests/fixtures/compose/schemas/shopping/discount.json new file mode 100644 index 0000000..3c53478 --- /dev/null +++ b/tests/fixtures/compose/schemas/shopping/discount.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/discount.json", + "name": "dev.ucp.shopping.discount", + "version": "2026-01-11", + "title": "Discount Extension", + "description": "Extends Checkout with discount code support.", + "$defs": { + "dev.ucp.shopping.checkout": { + "title": "Checkout with Discount", + "description": "Checkout extended with discount capability.", + "type": "object", + "additionalProperties": true, + "properties": { + "id": { "type": "string", "ucp_response": "required" }, + "status": { "type": "string", "ucp_response": "required" }, + "line_items": { "type": "array" }, + "ucp": { "type": "object", "additionalProperties": true }, + "discounts": { + "type": "object", + "additionalProperties": true, + "properties": { + "codes": { + "type": "array", + "items": { "type": "string" } + }, + "applied": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "code": { "type": "string" }, + "title": { "type": "string" }, + "amount": { "type": "integer", "minimum": 0 } + }, + "required": ["title", "amount"] + } + } + }, + "ucp_request": { + "create": "optional", + "update": "optional" + } + } + } + } + } +} diff --git a/tests/fixtures/compose/schemas/shopping/fulfillment.json b/tests/fixtures/compose/schemas/shopping/fulfillment.json new file mode 100644 index 0000000..d93381d --- /dev/null +++ b/tests/fixtures/compose/schemas/shopping/fulfillment.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/fulfillment.json", + "name": "dev.ucp.shopping.fulfillment", + "version": "2026-01-11", + "title": "Fulfillment Extension", + "description": "Extends Checkout with fulfillment support.", + "$defs": { + "dev.ucp.shopping.checkout": { + "title": "Checkout with Fulfillment", + "description": "Checkout extended with fulfillment capability.", + "type": "object", + "additionalProperties": true, + "properties": { + "id": { "type": "string", "ucp_response": "required" }, + "status": { "type": "string", "ucp_response": "required" }, + "line_items": { "type": "array" }, + "ucp": { "type": "object", "additionalProperties": true }, + "fulfillment": { + "type": "object", + "additionalProperties": true, + "properties": { + "method": { + "type": "string", + "enum": ["shipping", "pickup", "digital"] + }, + "address": { + "type": "object", + "additionalProperties": true, + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" }, + "postal_code": { "type": "string" } + } + } + }, + "ucp_request": { + "create": "optional", + "update": "optional" + } + } + } + } + } +} diff --git a/tests/fixtures/invalid/bad_annotation_type.json b/tests/fixtures/invalid/bad_annotation_type.json new file mode 100644 index 0000000..3d13133 --- /dev/null +++ b/tests/fixtures/invalid/bad_annotation_type.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": 123 + } + } +} diff --git a/tests/fixtures/invalid/unknown_visibility.json b/tests/fixtures/invalid/unknown_visibility.json new file mode 100644 index 0000000..9e9d0a5 --- /dev/null +++ b/tests/fixtures/invalid/unknown_visibility.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": "readonly" + } + } +} diff --git a/tests/resolve_test.rs b/tests/resolve_test.rs new file mode 100644 index 0000000..65c7894 --- /dev/null +++ b/tests/resolve_test.rs @@ -0,0 +1,1122 @@ +//! Integration tests for schema resolution. + +use serde_json::{json, Value}; +use ucp_schema::{resolve, Direction, ResolveError, ResolveOptions}; + +// === Visibility Parsing Tests === + +mod visibility_parsing { + use super::*; + + #[test] + fn shorthand_string() { + let schema = json!({ + "type": "object", + "properties": { + "status": { "type": "string", "ucp_request": "omit" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + assert!(result["properties"].get("status").is_none()); + } + + #[test] + fn object_form() { + let schema = json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": { + "create": "omit", + "update": "required" + } + } + } + }); + + // For create: id should be omitted + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + assert!(result["properties"].get("id").is_none()); + + // For update: id should be required + let options = ResolveOptions::new(Direction::Request, "update"); + let result = resolve(&schema, &options).unwrap(); + assert!(result["properties"].get("id").is_some()); + assert!(result["required"] + .as_array() + .unwrap() + .contains(&json!("id"))); + } + + #[test] + fn missing_annotation_defaults_to_include() { + let schema = json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Field should be included, required preserved + assert!(result["properties"].get("name").is_some()); + assert!(result["required"] + .as_array() + .unwrap() + .contains(&json!("name"))); + } + + #[test] + fn missing_operation_defaults_to_include() { + let schema = json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": { "create": "omit" } + } + } + }); + + // "read" not in dict, should default to include + let options = ResolveOptions::new(Direction::Request, "read"); + let result = resolve(&schema, &options).unwrap(); + assert!(result["properties"].get("id").is_some()); + } + + #[test] + fn both_request_and_response_annotations() { + let schema = json!({ + "type": "object", + "properties": { + "context": { + "type": "object", + "ucp_request": "optional", + "ucp_response": "omit" + } + } + }); + + // Request: should be present + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + assert!(result["properties"].get("context").is_some()); + + // Response: should be omitted + let options = ResolveOptions::new(Direction::Response, "create"); + let result = resolve(&schema, &options).unwrap(); + assert!(result["properties"].get("context").is_none()); + } +} + +// === Error Handling Tests === + +mod error_handling { + use super::*; + + #[test] + fn invalid_annotation_type_errors() { + let schema = json!({ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": 123 } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options); + + assert!(matches!( + result, + Err(ResolveError::InvalidAnnotationType { .. }) + )); + } + + #[test] + fn unknown_visibility_errors() { + let schema = json!({ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": "readonly" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options); + + assert!(matches!( + result, + Err(ResolveError::UnknownVisibility { value, .. }) if value == "readonly" + )); + } + + #[test] + fn unknown_visibility_in_dict_errors() { + let schema = json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": { "create": "maybe" } + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options); + + assert!(matches!( + result, + Err(ResolveError::UnknownVisibility { value, .. }) if value == "maybe" + )); + } +} + +// === Operation Normalization Tests === + +mod operation_normalization { + use super::*; + + #[test] + fn operations_are_case_insensitive() { + let schema = json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": { "create": "omit" } + } + } + }); + + // "Create" should match "create" + let options = ResolveOptions::new(Direction::Request, "Create"); + let result = resolve(&schema, &options).unwrap(); + assert!(result["properties"].get("id").is_none()); + + // "CREATE" should also match + let options = ResolveOptions::new(Direction::Request, "CREATE"); + let result = resolve(&schema, &options).unwrap(); + assert!(result["properties"].get("id").is_none()); + } +} + +// === Transformation Tests === + +mod transformation { + use super::*; + + #[test] + fn omit_removes_field_from_properties() { + let schema = json!({ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": "omit" }, + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + assert!(result["properties"].get("id").is_none()); + assert!(result["properties"].get("name").is_some()); + } + + #[test] + fn omit_removes_field_from_required() { + let schema = json!({ + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "ucp_request": "omit" }, + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + let required = result["required"].as_array().unwrap(); + assert!(!required.contains(&json!("id"))); + assert!(required.contains(&json!("name"))); + } + + #[test] + fn required_adds_to_required_array() { + let schema = json!({ + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": "required" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + let required = result["required"].as_array().unwrap(); + assert!(required.contains(&json!("id"))); + } + + #[test] + fn optional_removes_from_required_array() { + let schema = json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "ucp_request": "optional" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + let required = result["required"].as_array().unwrap(); + assert!(!required.contains(&json!("id"))); + } + + #[test] + fn include_preserves_original_state() { + let schema = json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Both fields present + assert!(result["properties"].get("id").is_some()); + assert!(result["properties"].get("name").is_some()); + + // Required preserved + let required = result["required"].as_array().unwrap(); + assert!(required.contains(&json!("id"))); + assert!(!required.contains(&json!("name"))); + } + + #[test] + fn all_fields_omitted_yields_empty_schema() { + let schema = json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "ucp_request": "omit" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + assert_eq!(result["properties"], json!({})); + assert_eq!(result["required"], json!([])); + } + + #[test] + fn annotations_stripped_from_output() { + let schema = json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "ucp_request": "required", + "ucp_response": "omit" + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // All UCP annotations should be stripped + assert!(result["properties"]["id"].get("ucp_request").is_none()); + assert!(result["properties"]["id"].get("ucp_response").is_none()); + } +} + +// === Required Array Tests === + +mod required_array { + use super::*; + + #[test] + fn omitted_field_removed_from_required() { + let schema = json!({ + "type": "object", + "required": ["id", "name", "email"], + "properties": { + "id": { "type": "string", "ucp_request": "omit" }, + "name": { "type": "string" }, + "email": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + let required = result["required"].as_array().unwrap(); + assert!(!required.contains(&json!("id"))); + assert!(required.contains(&json!("name"))); + assert!(required.contains(&json!("email"))); + } + + #[test] + fn required_field_added_to_required() { + let schema = json!({ + "type": "object", + "required": ["name"], + "properties": { + "id": { "type": "string", "ucp_request": "required" }, + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + let required = result["required"].as_array().unwrap(); + assert!(required.contains(&json!("id"))); + assert!(required.contains(&json!("name"))); + } + + #[test] + fn unrelated_required_fields_preserved() { + let schema = json!({ + "type": "object", + "required": ["name", "email"], + "properties": { + "id": { "type": "string", "ucp_request": "omit" }, + "name": { "type": "string" }, + "email": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + let required = result["required"].as_array().unwrap(); + assert!(required.contains(&json!("name"))); + assert!(required.contains(&json!("email"))); + } +} + +// === Recursion Tests (Phase 2) === + +mod recursion { + use super::*; + + #[test] + fn nested_properties() { + let schema = json!({ + "type": "object", + "properties": { + "buyer": { + "type": "object", + "properties": { + "email": { + "type": "string", + "ucp_request": { "create": "required" } + }, + "phone": { + "type": "string", + "ucp_request": { "create": "omit" } + } + } + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Nested email should be present and required + assert!(result["properties"]["buyer"]["properties"] + .get("email") + .is_some()); + let buyer_required = result["properties"]["buyer"]["required"] + .as_array() + .unwrap(); + assert!(buyer_required.contains(&json!("email"))); + + // Nested phone should be omitted + assert!(result["properties"]["buyer"]["properties"] + .get("phone") + .is_none()); + } + + #[test] + fn array_items() { + let schema = json!({ + "type": "object", + "properties": { + "line_items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sku": { "type": "string", "ucp_request": "required" }, + "price": { "type": "number", "ucp_request": "omit" } + } + } + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // sku in items should be present and required + assert!(result["properties"]["line_items"]["items"]["properties"] + .get("sku") + .is_some()); + + // price in items should be omitted + assert!(result["properties"]["line_items"]["items"]["properties"] + .get("price") + .is_none()); + } + + #[test] + fn defs() { + let schema = json!({ + "type": "object", + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" }, + "internal_id": { "type": "string", "ucp_request": "omit" } + } + } + }, + "properties": { + "shipping": { "$ref": "#/$defs/address" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // $defs should be transformed + assert!(result["$defs"]["address"]["properties"] + .get("street") + .is_some()); + assert!(result["$defs"]["address"]["properties"] + .get("internal_id") + .is_none()); + } + + #[test] + fn deep_nesting_five_levels() { + let schema = json!({ + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "object", + "properties": { + "level3": { + "type": "object", + "properties": { + "level4": { + "type": "object", + "properties": { + "level5": { + "type": "string", + "ucp_request": "omit" + } + } + } + } + } + } + } + } + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Deep nested field should be omitted + assert!( + result["properties"]["level1"]["properties"]["level2"]["properties"]["level3"] + ["properties"]["level4"]["properties"] + .get("level5") + .is_none() + ); + } +} + +// === Composition Tests (Phase 2) === + +mod composition { + use super::*; + + #[test] + fn allof_transforms_each_branch() { + let schema = json!({ + "allOf": [ + { + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": "omit" } + } + }, + { + "type": "object", + "properties": { + "name": { "type": "string", "ucp_request": "required" } + } + } + ] + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // First branch: id should be omitted + assert!(result["allOf"][0]["properties"].get("id").is_none()); + + // Second branch: name should be required + assert!(result["allOf"][1]["properties"].get("name").is_some()); + assert!(result["allOf"][1]["required"] + .as_array() + .unwrap() + .contains(&json!("name"))); + } + + #[test] + fn allof_tighten_omit_to_required() { + // Base has omit, extension adds required - should result in required (D2) + let schema = json!({ + "allOf": [ + { + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": { "create": "omit" } } + } + }, + { + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": { "create": "required" } } + } + } + ] + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // First branch: id omitted + assert!(result["allOf"][0]["properties"].get("id").is_none()); + + // Second branch: id required - this "tightens" the constraint + assert!(result["allOf"][1]["properties"].get("id").is_some()); + assert!(result["allOf"][1]["required"] + .as_array() + .unwrap() + .contains(&json!("id"))); + } + + #[test] + fn allof_loosen_required_to_omit_base_wins() { + // Base has required, extension has omit - base wins (D2 limitation) + let schema = json!({ + "allOf": [ + { + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": { "create": "required" } } + } + }, + { + "type": "object", + "properties": { + "id": { "type": "string", "ucp_request": { "create": "omit" } } + } + } + ] + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // First branch: id required (this wins due to JSON Schema allOf semantics) + assert!(result["allOf"][0]["properties"].get("id").is_some()); + assert!(result["allOf"][0]["required"] + .as_array() + .unwrap() + .contains(&json!("id"))); + + // Second branch: id omitted from this branch + assert!(result["allOf"][1]["properties"].get("id").is_none()); + + // Note: JSON Schema validation will require id because allOf is conjunctive + } + + #[test] + fn anyof_transforms_each_branch() { + let schema = json!({ + "anyOf": [ + { + "type": "object", + "properties": { + "card": { "type": "object", "ucp_request": "required" } + } + }, + { + "type": "object", + "properties": { + "token": { "type": "string", "ucp_request": "required" } + } + } + ] + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Both branches should be transformed + assert!(result["anyOf"][0]["properties"].get("card").is_some()); + assert!(result["anyOf"][1]["properties"].get("token").is_some()); + } + + #[test] + fn oneof_transforms_each_branch() { + let schema = json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "const": "credit_card" }, + "number": { "type": "string", "ucp_request": "required" } + } + }, + { + "type": "object", + "properties": { + "type": { "const": "bank_account" }, + "routing": { "type": "string", "ucp_request": "required" } + } + } + ] + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Both branches transformed + assert!(result["oneOf"][0]["properties"].get("number").is_some()); + assert!(result["oneOf"][1]["properties"].get("routing").is_some()); + } +} + +// === Additional Properties Tests (Phase 2) === + +mod additional_properties { + use super::*; + + #[test] + fn false_preserved_after_filtering() { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string", "ucp_request": "omit" }, + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // additionalProperties should remain false + assert_eq!(result["additionalProperties"], json!(false)); + + // id should be omitted + assert!(result["properties"].get("id").is_none()); + } + + #[test] + fn true_becomes_false_in_strict_mode() { + // Strict mode changes true to false to reject unknown fields + let schema = json!({ + "type": "object", + "additionalProperties": true, + "properties": { + "id": { "type": "string", "ucp_request": "omit" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(true); + let result = resolve(&schema, &options).unwrap(); + + assert_eq!(result["additionalProperties"], json!(false)); + } + + #[test] + fn true_unchanged_in_non_strict_mode() { + // Non-strict mode preserves original additionalProperties + let schema = json!({ + "type": "object", + "additionalProperties": true, + "properties": { + "id": { "type": "string", "ucp_request": "omit" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(false); + let result = resolve(&schema, &options).unwrap(); + + assert_eq!(result["additionalProperties"], json!(true)); + } + + #[test] + fn schema_form_transformed() { + let schema = json!({ + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "internal": { "type": "string", "ucp_request": "omit" } + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // The schema inside additionalProperties should be transformed + assert!(result["additionalProperties"]["properties"] + .get("internal") + .is_none()); + } +} + +// === Integration with real-world schema === + +mod integration { + use super::*; + use std::fs; + use std::path::Path; + + fn load_fixture(name: &str) -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name); + let content = fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("Failed to read fixture: {}", path.display())); + serde_json::from_str(&content).expect("Failed to parse fixture JSON") + } + + #[test] + fn checkout_create_request() { + let schema = load_fixture("checkout.json"); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // id should be omitted for create + assert!(result["properties"].get("id").is_none()); + + // line_items should be required + assert!(result["properties"].get("line_items").is_some()); + assert!(result["required"] + .as_array() + .unwrap() + .contains(&json!("line_items"))); + + // buyer should be optional (present but not required) + assert!(result["properties"].get("buyer").is_some()); + assert!(!result["required"] + .as_array() + .unwrap() + .contains(&json!("buyer"))); + + // status should be omitted + assert!(result["properties"].get("status").is_none()); + + // totals should be omitted + assert!(result["properties"].get("totals").is_none()); + } + + #[test] + fn checkout_update_request() { + let schema = load_fixture("checkout.json"); + let options = ResolveOptions::new(Direction::Request, "update"); + let result = resolve(&schema, &options).unwrap(); + + // id should be required for update + assert!(result["properties"].get("id").is_some()); + assert!(result["required"] + .as_array() + .unwrap() + .contains(&json!("id"))); + + // line_items should be optional for update + assert!(result["properties"].get("line_items").is_some()); + assert!(!result["required"] + .as_array() + .unwrap() + .contains(&json!("line_items"))); + } + + #[test] + fn checkout_read_request() { + let schema = load_fixture("checkout.json"); + let options = ResolveOptions::new(Direction::Request, "read"); + let result = resolve(&schema, &options).unwrap(); + + // id should be required for read + assert!(result["properties"].get("id").is_some()); + assert!(result["required"] + .as_array() + .unwrap() + .contains(&json!("id"))); + + // line_items should be omitted for read request + assert!(result["properties"].get("line_items").is_none()); + + // buyer should be omitted for read request + assert!(result["properties"].get("buyer").is_none()); + } + + #[test] + fn checkout_response() { + let schema = load_fixture("checkout.json"); + let options = ResolveOptions::new(Direction::Response, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Response should include most fields (no ucp_response annotations except on some) + assert!(result["properties"].get("id").is_some()); + assert!(result["properties"].get("status").is_some()); + assert!(result["properties"].get("totals").is_some()); + } + + #[test] + fn invalid_annotation_type_from_file() { + let schema = load_fixture("invalid/bad_annotation_type.json"); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options); + + assert!(matches!( + result, + Err(ResolveError::InvalidAnnotationType { .. }) + )); + } + + #[test] + fn unknown_visibility_from_file() { + let schema = load_fixture("invalid/unknown_visibility.json"); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options); + + assert!(matches!( + result, + Err(ResolveError::UnknownVisibility { .. }) + )); + } +} + +// === Strict Mode Tests === + +mod strict_mode { + use super::*; + + #[test] + fn default_is_not_strict() { + // Default options should have strict=false (respects schema extensibility) + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create"); + let result = resolve(&schema, &options).unwrap(); + + // Should NOT have additionalProperties: false added + assert!(result.get("additionalProperties").is_none()); + } + + #[test] + fn injects_additional_properties_false() { + // Object schemas without additionalProperties get false added in strict mode + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(true); + let result = resolve(&schema, &options).unwrap(); + + assert_eq!(result["additionalProperties"], json!(false)); + } + + #[test] + fn preserves_explicit_false() { + // Already false should stay false in strict mode + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(true); + let result = resolve(&schema, &options).unwrap(); + + assert_eq!(result["additionalProperties"], json!(false)); + } + + #[test] + fn preserves_custom_schema() { + // Custom additionalProperties schema should be preserved even in strict mode + let schema = json!({ + "type": "object", + "additionalProperties": { "type": "string" }, + "properties": { + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(true); + let result = resolve(&schema, &options).unwrap(); + + // Should preserve the schema, not replace with false + assert_eq!(result["additionalProperties"], json!({ "type": "string" })); + } + + #[test] + fn applies_to_nested_objects() { + // Nested objects should also get additionalProperties: false in strict mode + let schema = json!({ + "type": "object", + "properties": { + "address": { + "type": "object", + "properties": { + "city": { "type": "string" } + } + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(true); + let result = resolve(&schema, &options).unwrap(); + + // Root level + assert_eq!(result["additionalProperties"], json!(false)); + // Nested object + assert_eq!( + result["properties"]["address"]["additionalProperties"], + json!(false) + ); + } + + #[test] + fn applies_to_array_items() { + // Object items in arrays should also get additionalProperties: false in strict mode + let schema = json!({ + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(true); + let result = resolve(&schema, &options).unwrap(); + + // Array itself doesn't need additionalProperties + assert!(result.get("additionalProperties").is_none()); + // But items do + assert_eq!(result["items"]["additionalProperties"], json!(false)); + } + + #[test] + fn applies_to_defs() { + // Definitions should also be closed in strict mode + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "$defs": { + "Address": { + "type": "object", + "properties": { + "city": { "type": "string" } + } + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(true); + let result = resolve(&schema, &options).unwrap(); + + assert_eq!( + result["$defs"]["Address"]["additionalProperties"], + json!(false) + ); + } + + #[test] + fn applies_to_allof_branches() { + // allOf branches should be closed in strict mode + let schema = json!({ + "allOf": [ + { + "type": "object", + "properties": { + "id": { "type": "string" } + } + }, + { + "type": "object", + "properties": { + "name": { "type": "string" } + } + } + ] + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(true); + let result = resolve(&schema, &options).unwrap(); + + assert_eq!(result["allOf"][0]["additionalProperties"], json!(false)); + assert_eq!(result["allOf"][1]["additionalProperties"], json!(false)); + } + + #[test] + fn non_strict_mode_skips_injection() { + // With strict=false, additionalProperties should not be touched + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(false); + let result = resolve(&schema, &options).unwrap(); + + // Should not have additionalProperties added + assert!(result.get("additionalProperties").is_none()); + } + + #[test] + fn non_strict_mode_preserves_true() { + // With strict=false, explicit true should remain + let schema = json!({ + "type": "object", + "additionalProperties": true, + "properties": { + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(false); + let result = resolve(&schema, &options).unwrap(); + + assert_eq!(result["additionalProperties"], json!(true)); + } + + #[test] + fn detects_object_by_properties_key() { + // Even without "type": "object", presence of "properties" should trigger strict mode + let schema = json!({ + "properties": { + "name": { "type": "string" } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(true); + let result = resolve(&schema, &options).unwrap(); + + assert_eq!(result["additionalProperties"], json!(false)); + } +}