diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 859f48d6..ef67753f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -12,7 +12,7 @@ dependencies = [ "adc-sdk", "async-trait", "axum", - "reqwest", + "reqwest 0.12.28", "semver", "serde", "serde_json", @@ -70,7 +70,7 @@ dependencies = [ "futures", "http", "percent-encoding", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -133,6 +133,8 @@ name = "adc-sdk" version = "0.30.0" dependencies = [ "async-trait", + "jsonschema", + "schemars", "semver", "serde", "serde_json", @@ -140,6 +142,20 @@ dependencies = [ "thiserror", ] +[[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.5" @@ -149,6 +165,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -243,6 +265,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -301,6 +346,21 @@ 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.13.1" @@ -316,12 +376,24 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.12.1" @@ -341,6 +413,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -445,12 +519,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "console" version = "0.16.4" @@ -498,6 +591,16 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -600,6 +703,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deranged" version = "0.5.8" @@ -643,12 +752,33 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -671,12 +801,46 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -686,6 +850,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.33" @@ -793,6 +973,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -802,7 +996,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core", "wasm-bindgen", ] @@ -813,6 +1007,25 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "h2" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -835,6 +1048,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -912,6 +1130,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1140,6 +1359,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -1151,6 +1429,60 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ec8a241beed129f06114aa68007e905ca350e7baeb6e17a7631bb7978d91b2" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "reqwest 0.13.4", + "rustls", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91994f45017ed5e66aa8e59b8415f4cb033a6380d7200387b7cf117595fbdf85" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec7637f83e510868ae6ed625f7ebfbbde4554ee8ce49854caa5126a8b9b9ecb" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "num-cmp", + "num-traits", + "serde_json", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1217,6 +1549,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1243,12 +1581,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +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-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "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" @@ -1276,6 +1683,28 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -1301,6 +1730,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "plotters" version = "0.3.7" @@ -1449,6 +1884,12 @@ 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 = "r-efi" version = "6.0.0" @@ -1510,6 +1951,43 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6efa2154ea6f5ce0fdecdd2a8d18f2fa1a39a8fbba91564f555a592e4dce8278" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -1585,6 +2063,45 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1650,6 +2167,7 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -1658,6 +2176,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1668,12 +2198,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1700,12 +2258,69 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1742,6 +2357,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -1828,6 +2454,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1862,6 +2504,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2041,6 +2704,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2203,6 +2880,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2266,6 +2949,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2278,6 +2971,12 @@ 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 = "vt100" version = "0.16.2" @@ -2324,6 +3023,15 @@ 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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -2399,6 +3107,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -2567,6 +3284,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 31dd93e8..1699900d 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -28,6 +28,9 @@ tokio = "1" log = "0.4" tracing = "0.1" url = "2" +regex = "1" +schemars = "1" +jsonschema = "0.49" [profile.release] lto = "fat" diff --git a/rust/crates/adc-backend-apisix/src/backend.rs b/rust/crates/adc-backend-apisix/src/backend.rs index 10572e70..af9fce6e 100644 --- a/rust/crates/adc-backend-apisix/src/backend.rs +++ b/rust/crates/adc-backend-apisix/src/backend.rs @@ -13,7 +13,7 @@ use crate::operator::Operator; use crate::validator::Validator; /// Shared by `ping` and `resolved_version` — neither needs the response body. -const PROBE_PATH: &str = "/apisix/admin/routes?page=1&page_size=1"; +const PROBE_PATH: &str = "/apisix/admin/routes?page=1&page_size=10"; pub struct Backend { client: HttpClient, diff --git a/rust/crates/adc-cli/src/main.rs b/rust/crates/adc-cli/src/main.rs index dc27294f..0af9746f 100644 --- a/rust/crates/adc-cli/src/main.rs +++ b/rust/crates/adc-cli/src/main.rs @@ -91,6 +91,7 @@ async fn cmd_diff(args: DiffArgs) -> Result<(), CliError> { &exclude, &label_selector, args.backend.managed_by_label, + args.lint, ), ) .await?; @@ -124,6 +125,7 @@ async fn cmd_sync(args: SyncArgs) -> Result<(), CliError> { &exclude, &label_selector, args.backend.managed_by_label, + args.lint, ), ) .await?; @@ -226,10 +228,11 @@ async fn cmd_lint(args: LintArgs) -> Result<(), CliError> { &empty_types, &empty_labels, false, + true, ), ) .await?; - println!("Configuration is structurally valid."); + println!("Configuration is valid."); Ok(()) } @@ -246,6 +249,7 @@ async fn cmd_validate(args: ValidateArgs) -> Result<(), CliError> { &exclude, &label_selector, args.backend.managed_by_label, + args.lint, ), ) .await?; diff --git a/rust/crates/adc-cli/src/pipeline.rs b/rust/crates/adc-cli/src/pipeline.rs index 3059fc5d..5bf22a5c 100644 --- a/rust/crates/adc-cli/src/pipeline.rs +++ b/rust/crates/adc-cli/src/pipeline.rs @@ -175,20 +175,21 @@ fn resource_filter(args: &BackendArgs) -> Result { }) } -/// Loads, merges, and structurally parses the local configuration file(s). -/// Deserializing into `Configuration` here is the structural-validity gate -/// (unknown fields, wrong types, missing required fields all reject — -/// except inside a plugin config body: `Plugin`/`Plugins` are bare maps, -/// deliberately not `deny_unknown_fields`, since ADC can't know every -/// plugin's own schema) — the separate `--no-lint`/`Lint` step has nothing -/// left to check yet, since semantic validation (regex/cross-field rules) -/// hasn't landed (stage 2.2). +/// Loads, merges, and structurally parses the local configuration file(s), +/// then (unless `lint` is `false`, i.e. `--no-lint`) runs semantic +/// validation on top. Deserializing into `Configuration` is the +/// structural-validity gate (unknown fields, wrong types, missing required +/// fields all reject — except inside a plugin config body: `Plugin`/ +/// `Plugins` are bare maps, deliberately not `deny_unknown_fields`, since +/// ADC can't know every plugin's own schema) and always runs, regardless of +/// `lint` — only the semantic pass (`adc_sdk::lint::lint`) is skippable. pub async fn load_local( files: &[PathBuf], include: &HashSet, exclude: &HashSet, label_selector: &HashMap, managed_by_label: bool, + lint: bool, ) -> Result { let files = config::read_files(files).await?; let mut merged = config::merge_files(files)?; @@ -199,9 +200,27 @@ pub async fn load_local( let mut configuration: Configuration = serde_json::from_value(merged) .map_err(|e| CliError::msg(format!("invalid configuration: {e}")))?; config::filter_resource_types(&mut configuration, include, exclude); + if lint { + let issues = adc_sdk::lint::lint(&configuration); + if !issues.is_empty() { + return Err(CliError::msg(format_lint_issues(&issues))); + } + } Ok(configuration) } +/// Collects every lint violation into one multi-line message — mirrors the +/// TS CLI wrapping `z.prettifyError`'s multi-issue output into a single +/// thrown `Error`. +fn format_lint_issues(issues: &[adc_sdk::lint::LintIssue]) -> String { + let mut message = "Lint configuration\nThe following errors were found in configuration:\n".to_string(); + for issue in issues { + message.push_str(&format!(" - {issue}\n")); + } + message.pop(); + message +} + /// Converts each OpenAPI document into its own `Configuration`, then /// flattens their `services` into one — rejecting outright if two /// documents produce a same-named service, since a resource's id is diff --git a/rust/crates/adc-converter-openapi/Cargo.toml b/rust/crates/adc-converter-openapi/Cargo.toml index 4fe7509e..67063d6c 100644 --- a/rust/crates/adc-converter-openapi/Cargo.toml +++ b/rust/crates/adc-converter-openapi/Cargo.toml @@ -12,7 +12,7 @@ serde_json = { workspace = true } serde_yaml_ng = "0.10" unicode-normalization = "0.1" url = { workspace = true } -regex = "1" +regex = { workspace = true } thiserror = { workspace = true } log = { workspace = true } diff --git a/rust/crates/adc-sdk/Cargo.toml b/rust/crates/adc-sdk/Cargo.toml index 650a76a0..a72ef34f 100644 --- a/rust/crates/adc-sdk/Cargo.toml +++ b/rust/crates/adc-sdk/Cargo.toml @@ -12,3 +12,9 @@ sha1 = { workspace = true } async-trait = { workspace = true } semver = { workspace = true } thiserror = { workspace = true } +schemars = { workspace = true } +jsonschema = { workspace = true } + +[[bin]] +name = "export-schema" +path = "src/bin/export_schema.rs" diff --git a/rust/crates/adc-sdk/src/bin/export_schema.rs b/rust/crates/adc-sdk/src/bin/export_schema.rs new file mode 100644 index 00000000..8915a10c --- /dev/null +++ b/rust/crates/adc-sdk/src/bin/export_schema.rs @@ -0,0 +1,17 @@ +//! Regenerates `rust/schema.json` from the current `resources::Configuration` +//! shape. Not part of the crate's public library API or the shipped `adc` +//! CLI — a dev-only tool, mirroring the TS SDK's own `nx run cli:export-schema` +//! (`apps/cli/src/linter/exporter.ts`), which is likewise a standalone +//! script rather than an `adc` subcommand. +//! +//! Usage: `cargo run -p adc-sdk --bin export-schema`, from the workspace +//! root — the output path is anchored to this crate's manifest dir (same +//! convention as `tests/schema_json.rs`'s drift check), not the cwd. + +const SCHEMA_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../schema.json"); + +fn main() { + let schema = schemars::schema_for!(adc_sdk::resources::Configuration); + let json = serde_json::to_string_pretty(&schema).expect("schema serializes to JSON") + "\n"; + std::fs::write(SCHEMA_PATH, json).expect("writing schema.json"); +} diff --git a/rust/crates/adc-sdk/src/lib.rs b/rust/crates/adc-sdk/src/lib.rs index 74e5a9b6..6c8c9dfe 100644 --- a/rust/crates/adc-sdk/src/lib.rs +++ b/rust/crates/adc-sdk/src/lib.rs @@ -1,18 +1,21 @@ //! ADC's core data model: resource type definitions, the typed resource -//! layer for parsing declarative configuration (`resources` module), differ +//! layer for parsing declarative configuration (`resources` module), a +//! semantic-validation pass on top of that same model (`lint`), differ //! event types shared with backend/CLI consumers, a generic JSON value-diff //! utility, the `Backend` trait implemented by each gateway integration, and //! the `Converter` trait implemented by each source-format converter. //! -//! Not yet here: semantic validation (cross-field rules, regex, min/max) on -//! top of the `resources` types, and JSON Schema export. The differ's own -//! field-metadata table lives in `adc-differ` instead of here, since nothing -//! outside the differ consumes it. +//! `lint` is a separate call (`lint::lint`), not baked into `Deserialize` — +//! deserializing a `resources::Configuration` only ever enforces shape +//! (types, required fields, unknown fields); semantic rules run only when a +//! caller explicitly asks. The differ's own field-metadata table lives in +//! `adc-differ` instead of here, since nothing outside the differ consumes it. pub mod backend; pub mod converter; pub mod default_value; pub mod event; +pub mod lint; pub mod resource; pub mod resources; pub mod utils; @@ -26,7 +29,7 @@ pub use converter::{ConvertError, Converter}; pub use default_value::DefaultValue; pub use event::{Event, EventKind, EventType}; pub use resource::{FieldListType, ResourceType}; -pub use value_diff::{DiffPath, PathSegment, ValueDiff, diff_value}; +pub use value_diff::{DiffPath, PathSegment, ValueDiff, diff_value, format_path}; use serde_json::{Map, Value}; diff --git a/rust/crates/adc-sdk/src/lint.rs b/rust/crates/adc-sdk/src/lint.rs new file mode 100644 index 00000000..9fee9c01 --- /dev/null +++ b/rust/crates/adc-sdk/src/lint.rs @@ -0,0 +1,487 @@ +//! Semantic ("lint") validation on top of `resources::Configuration`. +//! +//! Structural validity (types, required fields, unknown fields) is already +//! guaranteed by the time a `Configuration` value exists — `serde` +//! deserialization is the gate for that (see `resources`' own doc comment). +//! This module is a separate, explicit pass on top of that same, +//! already-valid value, checking what `serde` can't express: string +//! length/format, numeric ranges, and a handful of genuine cross-field +//! rules. Ported from the TS SDK's Zod schema (`libs/sdk/src/core/schema.ts`). +//! +//! Most rules are declared once, as `#[schemars(...)]` attributes right on +//! the `resources` structs, and enforced here by compiling that same +//! derived JSON Schema with `jsonschema` and running it against the +//! serialized configuration — one declaration, used both to export +//! `schema.json` (see the `export-schema` binary) and to validate at +//! runtime, rather than maintaining two separate attribute sets that could +//! drift apart. Only the rules that are genuinely cross-field (and which, +//! verified against the TS SDK's own exported `schema.json`, aren't +//! expressible there either — TS's `.refine()`/`.superRefine()` calls don't +//! show up in its own JSON Schema export) get hand-written functions below. + +use std::sync::LazyLock; + +use crate::resources::{Configuration, Consumer, Service, Upstream}; +use crate::value_diff::{DiffPath, PathSegment, format_path}; + +static SCHEMA_VALIDATOR: LazyLock = LazyLock::new(|| { + let schema = schemars::schema_for!(Configuration); + let schema_value = serde_json::to_value(&schema).expect("derived schema serializes to JSON"); + jsonschema::validator_for(&schema_value).expect("derived schema is a valid JSON Schema") +}); + +/// One semantic-validation failure: where in the configuration it occurred, +/// and what's wrong. Structural failures never reach here — see this +/// module's doc comment. +#[derive(Debug, Clone, PartialEq)] +pub struct LintIssue { + pub path: DiffPath, + pub message: String, +} + +impl std::fmt::Display for LintIssue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.path.is_empty() { + write!(f, "{}", self.message) + } else { + write!(f, "{}: {}", format_path(&self.path), self.message) + } + } +} + +/// Runs every semantic rule against an already-structurally-valid +/// `Configuration`, collecting every violation rather than stopping at the +/// first one — matches the TS linter's behavior (a Zod `safeParse` collects +/// every issue in one pass, not fail-fast). +pub fn lint(config: &Configuration) -> Vec { + let instance = serde_json::to_value(config).expect("Configuration always serializes"); + let mut issues: Vec = SCHEMA_VALIDATOR + .iter_errors(&instance) + .map(|e| { + let path = json_pointer_to_diff_path(&e.instance_path().to_string()); + let message = masked_message(&e, &path); + LintIssue { path, message } + }) + .collect(); + check_cross_field_rules(config, &mut issues); + issues +} + +/// Field names that might carry secret material (inline PEM content, or the +/// plaintext of a `$secret://`/`$env://` reference) — `jsonschema`'s own +/// error `Display` embeds the failing instance value verbatim, which is fine +/// for debugging but not for a CLI's stdout/logs, so these get a generic +/// message instead of the real one. +const SENSITIVE_FIELD_NAMES: &[&str] = &["certificate", "key", "ca"]; + +fn masked_message(error: &jsonschema::ValidationError, path: &[PathSegment]) -> String { + let sensitive = matches!(path.last(), Some(PathSegment::Key(key)) if SENSITIVE_FIELD_NAMES.contains(&key.as_str())); + if sensitive { + "does not match the expected format (value redacted)".to_string() + } else { + error.to_string() + } +} + +/// Converts a JSON Pointer (e.g. `/services/0/upstream/nodes`) into a +/// `DiffPath`. Numeric segments become `Index`, everything else `Key`. +fn json_pointer_to_diff_path(pointer: &str) -> DiffPath { + pointer + .split('/') + .filter(|segment| !segment.is_empty()) + .map(|segment| match segment.parse::() { + Ok(index) => PathSegment::Index(index), + Err(_) => PathSegment::Key(segment.to_string()), + }) + .collect() +} + +fn push_key(base: &[PathSegment], key: &str) -> DiffPath { + let mut path = base.to_vec(); + path.push(PathSegment::Key(key.to_string())); + path +} + +fn push_index(base: &[PathSegment], index: usize) -> DiffPath { + let mut path = base.to_vec(); + path.push(PathSegment::Index(index)); + path +} + +fn check_cross_field_rules(config: &Configuration, issues: &mut Vec) { + for (i, service) in config.services.iter().flatten().enumerate() { + check_service(service, &push_index(&push_key(&[], "services"), i), issues); + } + for (i, consumer) in config.consumers.iter().flatten().enumerate() { + check_consumer_credentials(consumer, &push_index(&push_key(&[], "consumers"), i), issues); + } + for (i, group) in config.consumer_groups.iter().flatten().enumerate() { + let group_path = push_index(&push_key(&[], "consumer_groups"), i); + for (j, consumer) in group.consumers.iter().flatten().enumerate() { + check_consumer_credentials(consumer, &push_index(&push_key(&group_path, "consumers"), j), issues); + } + } +} + +/// `checks.active`/`checks.passive` interlock isn't ported: `active` is a +/// required (non-`Option`) field on `UpstreamHealthCheck`, so the TS refine +/// rule tying it to `passive` is unconditionally true given valid input in +/// both languages — a dead rule, not a gap. +fn check_service(service: &Service, path: &[PathSegment], issues: &mut Vec) { + if let Some(prefix) = &service.path_prefix + && !prefix.starts_with('/') + { + issues.push(LintIssue { + path: push_key(path, "path_prefix"), + message: "must start with \"/\"".to_string(), + }); + } + if service.upstreams.is_some() && service.upstream.is_none() { + issues.push(LintIssue { + path: path.to_vec(), + message: "the default upstream must be set with \"upstream\" when multiple upstreams are set via \"upstreams\"".to_string(), + }); + } + if let Some(upstream) = &service.upstream { + check_upstream_discovery(upstream, &push_key(path, "upstream"), issues); + } + for (i, upstream) in service.upstreams.iter().flatten().enumerate() { + check_upstream_discovery(upstream, &push_index(&push_key(path, "upstreams"), i), issues); + } +} + +/// `nodes` and service discovery (`discovery_type`+`service_name`) are +/// mutually exclusive, and exactly one must be set — matches TS's refine: +/// `(nodes && !discovery_type && !service_name) || (discovery_type && +/// service_name && !nodes)`. +fn check_upstream_discovery(upstream: &Upstream, path: &[PathSegment], issues: &mut Vec) { + let nodes_only = upstream.nodes.is_some() && upstream.discovery_type.is_none() && upstream.service_name.is_none(); + let discovery_only = + upstream.discovery_type.is_some() && upstream.service_name.is_some() && upstream.nodes.is_none(); + if !(nodes_only || discovery_only) { + issues.push(LintIssue { + path: path.to_vec(), + message: "upstream must either specify nodes or use service discovery (\"discovery_type\" + \"service_name\"), not both or neither" + .to_string(), + }); + } +} + +const ALLOWED_CREDENTIAL_TYPES: [&str; 4] = ["key-auth", "basic-auth", "jwt-auth", "hmac-auth"]; + +fn check_consumer_credentials(consumer: &Consumer, path: &[PathSegment], issues: &mut Vec) { + for (i, credential) in consumer.credentials.iter().flatten().enumerate() { + if !ALLOWED_CREDENTIAL_TYPES.contains(&credential.r#type.as_str()) { + issues.push(LintIssue { + path: push_key(&push_index(&push_key(path, "credentials"), i), "type"), + message: "consumer credential only supports \"key-auth\", \"basic-auth\", \"jwt-auth\" and \"hmac-auth\" types".to_string(), + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resources::{ConsumerCredential, ConsumerGroup, ServiceRoutes, UpstreamNode}; + + fn minimal_upstream_with_nodes() -> Upstream { + Upstream { + id: None, + name: None, + description: None, + labels: None, + r#type: Default::default(), + hash_on: None, + key: None, + checks: None, + nodes: Some(vec![UpstreamNode { host: "127.0.0.1".into(), port: 80, weight: 1, priority: 0, metadata: None }]), + scheme: Default::default(), + retries: None, + retry_timeout: None, + timeout: None, + tls: None, + keepalive_pool: None, + pass_host: Default::default(), + upstream_host: None, + service_name: None, + discovery_type: None, + discovery_args: None, + } + } + + fn minimal_service() -> Service { + Service { + id: None, + name: "svc".into(), + description: None, + labels: None, + upstream: Some(minimal_upstream_with_nodes()), + upstreams: None, + plugins: None, + path_prefix: None, + strip_path_prefix: None, + hosts: None, + routes: Some(ServiceRoutes::Http { routes: vec![] }), + } + } + + fn empty_config() -> Configuration { + Configuration { services: None, ssls: None, consumers: None, consumer_groups: None, global_rules: None, plugin_metadata: None } + } + + #[test] + fn a_valid_configuration_lints_clean() { + let config = Configuration { services: Some(vec![minimal_service()]), ..empty_config() }; + assert_eq!(lint(&config), Vec::new()); + } + + #[test] + fn upstream_with_both_nodes_and_discovery_is_rejected() { + let mut upstream = minimal_upstream_with_nodes(); + upstream.discovery_type = Some("dns".into()); + upstream.service_name = Some("svc.local".into()); + let service = Service { upstream: Some(upstream), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + let issues = lint(&config); + assert_eq!(issues.len(), 1); + assert_eq!(format_path(&issues[0].path), "services[0].upstream"); + } + + #[test] + fn upstream_with_neither_nodes_nor_discovery_is_rejected() { + let mut upstream = minimal_upstream_with_nodes(); + upstream.nodes = None; + let service = Service { upstream: Some(upstream), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + assert_eq!(lint(&config).len(), 1); + } + + #[test] + fn upstream_with_only_discovery_is_accepted() { + let mut upstream = minimal_upstream_with_nodes(); + upstream.nodes = None; + upstream.discovery_type = Some("dns".into()); + upstream.service_name = Some("svc.local".into()); + let service = Service { upstream: Some(upstream), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + assert_eq!(lint(&config), Vec::new()); + } + + #[test] + fn path_prefix_without_a_leading_slash_is_rejected() { + let service = Service { path_prefix: Some("no-slash".into()), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + let issues = lint(&config); + assert_eq!(issues.len(), 1); + assert_eq!(format_path(&issues[0].path), "services[0].path_prefix"); + } + + #[test] + fn path_prefix_with_a_leading_slash_is_accepted() { + let service = Service { path_prefix: Some("/api".into()), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + assert_eq!(lint(&config), Vec::new()); + } + + #[test] + fn upstreams_without_a_default_upstream_is_rejected() { + let mut named = minimal_upstream_with_nodes(); + named.name = Some("u1".into()); + let service = Service { upstream: None, upstreams: Some(vec![named]), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + assert_eq!(lint(&config).len(), 1); + } + + #[test] + fn upstreams_with_a_default_upstream_is_accepted() { + let mut named = minimal_upstream_with_nodes(); + named.name = Some("u1".into()); + let service = Service { upstreams: Some(vec![named]), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + assert_eq!(lint(&config), Vec::new()); + } + + #[test] + fn an_unnamed_entry_in_upstreams_is_rejected_by_the_schema() { + let unnamed = minimal_upstream_with_nodes(); + let service = Service { upstreams: Some(vec![unnamed]), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + assert!(!lint(&config).is_empty()); + } + + /// The embedded default upstream isn't an addressable resource on its + /// own — any `id` on it is rejected, unlike `upstreams[]` items. + #[test] + fn an_id_on_the_default_upstream_is_rejected_by_the_schema() { + let mut upstream = minimal_upstream_with_nodes(); + upstream.id = Some("u1".into()); + let service = Service { upstream: Some(upstream), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + assert!(!lint(&config).is_empty()); + } + + #[test] + fn an_id_on_a_named_upstreams_entry_is_accepted() { + let mut named = minimal_upstream_with_nodes(); + named.id = Some("u1".into()); + named.name = Some("u1".into()); + let service = Service { upstreams: Some(vec![named]), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + assert_eq!(lint(&config), Vec::new()); + } + + #[test] + fn an_id_with_disallowed_characters_is_rejected_by_the_schema() { + let service = Service { id: Some("not valid!".into()), ..minimal_service() }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + let issues = lint(&config); + assert_eq!(issues.len(), 1); + assert_eq!(format_path(&issues[0].path), "services[0].id"); + } + + #[test] + fn an_ssl_certificate_shorter_than_the_minimum_length_is_rejected_by_the_schema() { + use crate::resources::{SSL, SSLCertificate}; + let ssl = SSL { + id: None, + labels: None, + r#type: Default::default(), + snis: vec!["example.com".into()], + certificates: vec![SSLCertificate { certificate: "short".into(), key: "x".repeat(32) }], + client: None, + ssl_protocols: None, + }; + let config = Configuration { ssls: Some(vec![ssl]), ..empty_config() }; + assert!(!lint(&config).is_empty()); + } + + #[test] + fn an_ssl_certificate_as_a_secret_reference_is_accepted() { + use crate::resources::{SSL, SSLCertificate}; + let ssl = SSL { + id: None, + labels: None, + r#type: Default::default(), + snis: vec!["example.com".into()], + certificates: vec![SSLCertificate { certificate: "$secret://vault/cert".into(), key: "$env://TLS_KEY".into() }], + client: None, + ssl_protocols: None, + }; + let config = Configuration { ssls: Some(vec![ssl]), ..empty_config() }; + assert_eq!(lint(&config), Vec::new()); + } + + /// An invalid `certificate`/`key` value fails the schema's `anyOf` + /// check — `jsonschema`'s default error message would otherwise embed + /// the offending value verbatim, leaking secret material into lint output. + #[test] + fn an_invalid_ssl_key_value_is_not_echoed_into_the_lint_message() { + use crate::resources::{SSL, SSLCertificate}; + // Under the 32-char minimum, so it fails validation (too short to be + // real PEM content, and doesn't match the `$secret://`/`$env://` + // reference pattern either) — short enough on purpose, to prove the + // failure path in particular doesn't echo it back. + let secret_value = "sk_live_UNIQUE_MARKER_9f3a7c"; + let ssl = SSL { + id: None, + labels: None, + r#type: Default::default(), + snis: vec!["example.com".into()], + certificates: vec![SSLCertificate { certificate: "x".repeat(128), key: secret_value.into() }], + client: None, + ssl_protocols: None, + }; + let config = Configuration { ssls: Some(vec![ssl]), ..empty_config() }; + let issues = lint(&config); + assert_eq!(issues.len(), 1); + assert!( + !issues[0].message.contains(secret_value), + "lint message leaked the secret value: {}", + issues[0].message + ); + } + + #[test] + fn a_consumer_credential_with_a_disallowed_type_is_rejected() { + let consumer = Consumer { + username: "u".into(), + description: None, + labels: None, + plugins: None, + credentials: Some(vec![ConsumerCredential { + id: None, + name: "c".into(), + description: None, + labels: None, + r#type: "totally-made-up-auth".into(), + config: Default::default(), + }]), + }; + let config = Configuration { consumers: Some(vec![consumer]), ..empty_config() }; + let issues = lint(&config); + assert_eq!(issues.len(), 1); + assert_eq!(format_path(&issues[0].path), "consumers[0].credentials[0].type"); + } + + #[test] + fn a_consumer_credential_with_an_allowed_type_is_accepted() { + let consumer = Consumer { + username: "u".into(), + description: None, + labels: None, + plugins: None, + credentials: Some(vec![ConsumerCredential { + id: None, + name: "c".into(), + description: None, + labels: None, + r#type: "key-auth".into(), + config: Default::default(), + }]), + }; + let config = Configuration { consumers: Some(vec![consumer]), ..empty_config() }; + assert_eq!(lint(&config), Vec::new()); + } + + #[test] + fn a_credential_type_violation_nested_inside_a_consumer_group_is_found() { + let consumer = Consumer { + username: "u".into(), + description: None, + labels: None, + plugins: None, + credentials: Some(vec![ConsumerCredential { + id: None, + name: "c".into(), + description: None, + labels: None, + r#type: "bogus".into(), + config: Default::default(), + }]), + }; + let group = ConsumerGroup { id: None, name: "g".into(), description: None, labels: None, plugins: None, consumers: Some(vec![consumer]) }; + let config = Configuration { consumer_groups: Some(vec![group]), ..empty_config() }; + let issues = lint(&config); + assert_eq!(issues.len(), 1); + assert_eq!(format_path(&issues[0].path), "consumer_groups[0].consumers[0].credentials[0].type"); + } + + #[test] + fn multiple_violations_are_all_collected_in_one_pass() { + let mut upstream = minimal_upstream_with_nodes(); + upstream.discovery_type = Some("dns".into()); + upstream.service_name = Some("svc.local".into()); + let service = Service { + id: Some("bad id!".into()), + path_prefix: Some("no-slash".into()), + upstream: Some(upstream), + ..minimal_service() + }; + let config = Configuration { services: Some(vec![service]), ..empty_config() }; + let issues = lint(&config); + // id charset (schema), path_prefix leading slash, nodes/discovery + // conflict — three independent violations, all reported together. + assert_eq!(issues.len(), 3); + } +} diff --git a/rust/crates/adc-sdk/src/resources/common.rs b/rust/crates/adc-sdk/src/resources/common.rs index a8b760f2..eca8040a 100644 --- a/rust/crates/adc-sdk/src/resources/common.rs +++ b/rust/crates/adc-sdk/src/resources/common.rs @@ -1,5 +1,6 @@ //! Types shared across multiple resource definitions. +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -7,7 +8,7 @@ use std::collections::HashMap; /// A resource's labels: each value is a single string or a list of strings. pub type Labels = HashMap; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] pub enum LabelValue { Single(String), @@ -26,11 +27,32 @@ pub type Plugins = serde_json::Map; /// evaluated by the gateway at request time. pub type Expr = Vec; +/// `#[schemars(range(...))]` only sets inclusive `minimum`/`maximum` — a +/// timeout of exactly 0 makes no sense, so this hand-writes the +/// `exclusiveMinimum` keyword `range` can't express. +fn positive_number_schema(_gen: &mut SchemaGenerator) -> Schema { + json_schema!({"type": "number", "exclusiveMinimum": 0}) +} + +// id/name/description/port bounds are repeated directly on each field via +// stacked `#[schemars(length(...), regex(...))]`/`range(...)` attributes +// rather than factored into a shared `schema_with` fn: on an `Option` +// field, `schema_with` needs an explicit `#[serde(default)]` alongside it +// (to stay out of `required`) and its returned schema needs its own +// `"anyOf": [..., {"type": "null"}]` (Option doesn't get that for free +// the way it does for plain field generation) — verified empirically, easy +// to get wrong silently. Plain stacked attributes don't have either problem. +// `schema_with` is still used below and elsewhere for schemas no stackable +// attribute can express (`anyOf` unions, `exclusiveMinimum`, `allOf`). + /// Connect/send/read timeouts in seconds, shared by upstream and route configs. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Timeout { + #[schemars(schema_with = "positive_number_schema")] pub connect: f64, + #[schemars(schema_with = "positive_number_schema")] pub send: f64, + #[schemars(schema_with = "positive_number_schema")] pub read: f64, } diff --git a/rust/crates/adc-sdk/src/resources/consumer.rs b/rust/crates/adc-sdk/src/resources/consumer.rs index 2ad4f985..17f5b746 100644 --- a/rust/crates/adc-sdk/src/resources/consumer.rs +++ b/rust/crates/adc-sdk/src/resources/consumer.rs @@ -1,5 +1,6 @@ //! The `Consumer`, `ConsumerCredential` and `ConsumerGroup` resources. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::common::{Labels, Plugin, Plugins}; @@ -8,13 +9,16 @@ use super::common::{Labels, Plugin, Plugins}; /// `type` is kept as a plain string rather than a closed enum: the 4-value /// restriction ("key-auth"/"basic-auth"/"jwt-auth"/"hmac-auth") is a semantic /// rule for the validation layer, not encoded here. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ConsumerCredential { #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 256), regex(pattern = r"^[a-zA-Z0-9-_.]+$"))] pub id: Option, + #[schemars(length(min = 1, max = 65536))] pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 65536))] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option, @@ -25,11 +29,13 @@ pub struct ConsumerCredential { } /// A consumer, identified by `username` rather than `name`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Consumer { + #[schemars(length(min = 1, max = 65536))] pub username: String, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 65536))] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option, @@ -41,13 +47,16 @@ pub struct Consumer { } /// A named group of consumers sharing plugin configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ConsumerGroup { #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 256), regex(pattern = r"^[a-zA-Z0-9-_.]+$"))] pub id: Option, + #[schemars(length(min = 1, max = 65536))] pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 65536))] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option, diff --git a/rust/crates/adc-sdk/src/resources/mod.rs b/rust/crates/adc-sdk/src/resources/mod.rs index d03347b1..78b09efe 100644 --- a/rust/crates/adc-sdk/src/resources/mod.rs +++ b/rust/crates/adc-sdk/src/resources/mod.rs @@ -33,6 +33,7 @@ pub use upstream::{ UpstreamKeepalivePool, UpstreamNode, UpstreamPassHost, UpstreamScheme, UpstreamTls, }; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// A global rule is just a plugin config map applied gateway-wide. @@ -43,7 +44,7 @@ pub type PluginMetadata = Plugins; /// The external, user-facing declarative config file shape: nested /// sub-resources embedded under their parent, no top-level /// routes/upstreams/consumer_credentials. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Configuration { #[serde(skip_serializing_if = "Option::is_none")] @@ -64,7 +65,7 @@ pub struct Configuration { /// routes/stream_routes/consumer_credentials/upstreams alongside the nested /// sub-resources, so every resource is also reachable directly by its own /// collection field. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct InternalConfiguration { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/rust/crates/adc-sdk/src/resources/route.rs b/rust/crates/adc-sdk/src/resources/route.rs index a334946d..feb9553f 100644 --- a/rust/crates/adc-sdk/src/resources/route.rs +++ b/rust/crates/adc-sdk/src/resources/route.rs @@ -1,10 +1,37 @@ //! The `Route` (HTTP) and `StreamRoute` (TCP/UDP) resources. +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::{Deserialize, Serialize}; use super::common::{Expr, Labels, Plugins, Timeout}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// `remote_addrs` items: IPv4, IPv6, IPv4 CIDR, or IPv6 CIDR. Patterns copied +/// verbatim from the TS SDK's exported `schema.json` (Zod's own +/// `z.ipv4()`/`z.ipv6()`/`z.cidrv4()`/`z.cidrv6()`), not hand-rolled here. +/// The outer `anyOf`+`null` (rather than just the array schema) is what +/// keeps this field correctly excluded from `required` — `schema_with` +/// replaces schemars' usual `Option` handling, so it has to be redone +/// here explicitly (see `common.rs`'s note on `schema_with`). +fn ip_or_cidr_schema(_gen: &mut SchemaGenerator) -> Schema { + json_schema!({ + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + {"type": "string", "format": "ipv4", "pattern": r"^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$"}, + {"type": "string", "format": "ipv6", "pattern": r"^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$"}, + {"type": "string", "format": "cidrv4", "pattern": r"^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$"}, + {"type": "string", "format": "cidrv6", "pattern": r"^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$"} + ] + } + }, + {"type": "null"} + ] + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum HttpMethod { #[serde(rename = "GET")] Get, @@ -29,19 +56,24 @@ pub enum HttpMethod { } /// An HTTP route: matches requests by URI/host/method and applies plugins to them. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Route { #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 256), regex(pattern = r"^[a-zA-Z0-9-_.]+$"))] pub id: Option, + #[schemars(length(min = 1, max = 65536))] pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 65536))] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(inner(length(min = 1)))] pub hosts: Option>, + #[schemars(length(min = 1))] pub uris: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, @@ -50,10 +82,12 @@ pub struct Route { #[serde(skip_serializing_if = "Option::is_none")] pub vars: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub methods: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub enable_websocket: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "ip_or_cidr_schema")] pub remote_addrs: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub plugins: Option, @@ -92,13 +126,16 @@ mod tests { } /// A stream (TCP/UDP/TLS) route: matches connections by address/SNI rather than URI. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct StreamRoute { #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 256), regex(pattern = r"^[a-zA-Z0-9-_.]+$"))] pub id: Option, + #[schemars(length(min = 1, max = 65536))] pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 65536))] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option, @@ -111,7 +148,9 @@ pub struct StreamRoute { #[serde(skip_serializing_if = "Option::is_none")] pub server_addr: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] pub server_port: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub sni: Option, } diff --git a/rust/crates/adc-sdk/src/resources/service.rs b/rust/crates/adc-sdk/src/resources/service.rs index 08c3cf72..c873e984 100644 --- a/rust/crates/adc-sdk/src/resources/service.rs +++ b/rust/crates/adc-sdk/src/resources/service.rs @@ -4,12 +4,52 @@ //! ("HTTP routes and Stream routes are mutually exclusive") is enforced here //! instead, via `ServiceRoutes` — see its doc comment for why and how. +use schemars::{JsonSchema, Schema, SchemaGenerator}; use serde::{Deserialize, Serialize}; use super::common::{Labels, Plugins}; use super::route::{Route, StreamRoute}; use super::upstream::Upstream; +/// `Upstream.name` is optional everywhere in its own schema (it doubles as +/// `Service.upstream`, the unnamed default), but each entry in +/// `Service.upstreams[]` needs a name to be addressable — matches the TS SDK's +/// exported `schema.json`, which shows `upstreams[].items.required == +/// ["name"]` even though the base `Upstream` schema has no required list at +/// all. `allOf` layers the extra `required` on top of `Upstream`'s own +/// (possibly `$ref`'d) schema rather than trying to mutate it in place. The +/// outer `anyOf`+`null` is needed because `schema_with` disables schemars' +/// usual `Option` handling (see `common.rs`'s note on `schema_with`) — +/// `upstreams` is itself optional even though each of its items needs a name. +fn upstreams_schema(generator: &mut SchemaGenerator) -> Schema { + let item = generator.subschema_for::(); + let value = serde_json::json!({ + "anyOf": [ + {"type": "array", "items": {"allOf": [item, {"required": ["name"]}]}}, + {"type": "null"} + ] + }); + Schema::try_from(value).expect("valid schema") +} + +/// `Service.upstream` (the singular, embedded default upstream) must never +/// carry an `id`: it isn't an addressable resource of its own — the id it +/// ends up with, if the backend splits it into a real upstream sub-resource, +/// is generated internally. `Service.upstreams[]` items *are* addressable on +/// their own and do allow an explicit `id` (see `upstreams_schema`, which +/// doesn't restrict it). `not: {required: ["id"]}` rejects the key if +/// present, rather than just leaving it optional. +fn default_upstream_schema(generator: &mut SchemaGenerator) -> Schema { + let item = generator.subschema_for::(); + let value = serde_json::json!({ + "anyOf": [ + {"allOf": [item, {"not": {"required": ["id"]}}]}, + {"type": "null"} + ] + }); + Schema::try_from(value).expect("valid schema") +} + /// `Service.routes`/`Service.stream_routes` are two sibling JSON keys, at /// most one of which may be present — a service either proxies HTTP or a /// stream (TCP/UDP), never both. This enum makes the other combination @@ -23,7 +63,7 @@ use super::upstream::Upstream; /// — `ServiceRaw` derives normally with both fields as plain /// `Option>`, and `TryFrom` is where the two are reconciled into this /// enum (or rejected). -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] #[serde(untagged)] pub enum ServiceRoutes { Http { routes: Vec }, @@ -60,7 +100,7 @@ impl ServiceRoutes { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(try_from = "ServiceRaw")] pub struct Service { #[serde(skip_serializing_if = "Option::is_none")] @@ -91,19 +131,33 @@ pub struct Service { /// Plain-shape deserialization target for `Service` — see `ServiceRoutes`'s /// doc comment for why this indirection exists. Every field here must stay /// in sync with `Service`'s (routes/stream_routes excepted). -#[derive(Debug, Deserialize)] +/// +/// This is also where the `#[schemars(...)]` field attributes live rather +/// than on `Service` itself: `#[serde(try_from = "ServiceRaw")]` makes +/// `schemars` derive `Service`'s exported schema *from* `ServiceRaw`'s shape +/// (the actual wire input), not `Service`'s own internal representation — +/// putting the attributes on `Service` would silently be dead code. +#[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] struct ServiceRaw { + #[schemars(length(min = 1, max = 256), regex(pattern = r"^[a-zA-Z0-9-_.]+$"))] id: Option, + #[schemars(length(min = 1, max = 65536))] name: String, + #[schemars(length(max = 65536))] description: Option, labels: Option, + #[serde(default)] + #[schemars(schema_with = "default_upstream_schema")] upstream: Option, + #[serde(default)] + #[schemars(schema_with = "upstreams_schema")] upstreams: Option>, plugins: Option, path_prefix: Option, strip_path_prefix: Option, + #[schemars(inner(length(min = 1)))] hosts: Option>, #[serde(default)] diff --git a/rust/crates/adc-sdk/src/resources/ssl.rs b/rust/crates/adc-sdk/src/resources/ssl.rs index 6ed4f532..5147c0d4 100644 --- a/rust/crates/adc-sdk/src/resources/ssl.rs +++ b/rust/crates/adc-sdk/src/resources/ssl.rs @@ -1,10 +1,36 @@ //! The `SSL` (certificate) resource. +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::{Deserialize, Serialize}; use super::common::Labels; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +/// A PEM-ish blob (certificate) or a `$secret://`/`$env://` reference in its +/// place — an `anyOf` union, not a structural type, because the field stays +/// plain `String` (see `SSLCertificate`'s doc comment). Pattern/lengths +/// copied from the TS SDK's exported `schema.json` (Zod's own +/// `z.union([...])` for this field). +fn certificate_schema(_gen: &mut SchemaGenerator) -> Schema { + json_schema!({ + "anyOf": [ + {"type": "string", "minLength": 128, "maxLength": 65536}, + {"type": "string", "pattern": r"^\$(secret|env):\/\/"} + ] + }) +} + +/// Same shape as `certificate_schema`, but for a private key — TS requires a +/// shorter minimum length (32 vs 128) for these. +fn pem_key_or_secret_ref_schema(_gen: &mut SchemaGenerator) -> Schema { + json_schema!({ + "anyOf": [ + {"type": "string", "minLength": 32, "maxLength": 65536}, + {"type": "string", "pattern": r"^\$(secret|env):\/\/"} + ] + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] pub enum SslType { #[default] #[serde(rename = "server")] @@ -17,7 +43,7 @@ fn default_client_depth() -> u32 { 1 } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum SslProtocol { #[serde(rename = "TLSv1.1")] Tlsv1_1, @@ -31,37 +57,46 @@ pub enum SslProtocol { /// reference string instead of inline PEM content — that's a semantic /// (regex-union) check, not a structural one, so both fields are plain /// `String` here. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SSLCertificate { + #[schemars(schema_with = "certificate_schema")] pub certificate: String, + #[schemars(schema_with = "pem_key_or_secret_ref_schema")] pub key: String, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SslClient { + #[schemars(schema_with = "certificate_schema")] pub ca: String, #[serde(default = "default_client_depth")] + #[schemars(range(min = 0))] pub depth: u32, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub skip_mtls_uri_regex: Option>, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SSL { #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 256), regex(pattern = r"^[a-zA-Z0-9-_.]+$"))] pub id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option, #[serde(rename = "type", default)] pub r#type: SslType, + #[schemars(length(min = 1), inner(length(min = 1)))] pub snis: Vec, + #[schemars(length(min = 1))] pub certificates: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub client: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub ssl_protocols: Option>, } diff --git a/rust/crates/adc-sdk/src/resources/upstream.rs b/rust/crates/adc-sdk/src/resources/upstream.rs index 7aa5b0d3..7a4ad113 100644 --- a/rust/crates/adc-sdk/src/resources/upstream.rs +++ b/rust/crates/adc-sdk/src/resources/upstream.rs @@ -6,12 +6,13 @@ //! describing an optional shape. Fields that are merely optional with no //! default stay `Option` and are genuinely absent when not provided. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use super::common::{Labels, Timeout}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] pub enum UpstreamBalancer { #[default] #[serde(rename = "roundrobin")] @@ -24,7 +25,7 @@ pub enum UpstreamBalancer { Ewma, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] pub enum UpstreamScheme { #[serde(rename = "grpc")] Grpc, @@ -45,7 +46,7 @@ pub enum UpstreamScheme { Kafka, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] pub enum UpstreamPassHost { #[default] #[serde(rename = "pass")] @@ -56,7 +57,7 @@ pub enum UpstreamPassHost { Rewrite, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] pub enum UpstreamHealthCheckType { #[default] #[serde(rename = "http")] @@ -108,11 +109,14 @@ fn default_keepalive_requests() -> u32 { } /// A single upstream target: host, port, and load-balancing weight. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpstreamNode { + #[schemars(length(min = 1))] pub host: String, + #[schemars(range(min = 1))] pub port: u16, + #[schemars(range(min = 0))] pub weight: i64, // A count, not a duration: unlike `Timeout`/`retry_timeout`, there's no // real-world fractional priority — matches the gateway's own schema @@ -125,62 +129,76 @@ pub struct UpstreamNode { } /// Passive-health-check thresholds for marking a target healthy. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpstreamHealthCheckPassiveHealthy { #[serde(default = "default_healthy_http_statuses")] + #[schemars(length(min = 1), inner(range(min = 200, max = 599)))] pub http_statuses: Vec, #[serde(default = "default_successes")] + #[schemars(range(min = 1, max = 254))] pub successes: u32, } /// Passive-health-check thresholds for marking a target unhealthy. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpstreamHealthCheckPassiveUnhealthy { #[serde(default = "default_unhealthy_http_statuses")] + #[schemars(length(min = 1), inner(range(min = 200, max = 599)))] pub http_statuses: Vec, #[serde(default = "default_http_failures")] + #[schemars(range(min = 1, max = 254))] pub http_failures: u32, #[serde(default = "default_tcp_failures")] + #[schemars(range(min = 1, max = 254))] pub tcp_failures: u32, #[serde(default = "default_timeouts")] + #[schemars(range(min = 1, max = 254))] pub timeouts: u32, } /// Active-health-check thresholds for marking a target healthy, plus the /// polling interval (seconds, default 1) active checks need that passive /// checks don't. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpstreamHealthCheckActiveHealthy { #[serde(default = "default_healthy_http_statuses")] + #[schemars(length(min = 1), inner(range(min = 200, max = 599)))] pub http_statuses: Vec, #[serde(default = "default_successes")] + #[schemars(range(min = 1, max = 254))] pub successes: u32, #[serde(default = "default_interval")] + #[schemars(range(min = 1))] pub interval: u32, } /// Active-health-check thresholds for marking a target unhealthy, plus the /// polling interval (seconds, default 1) active checks need that passive /// checks don't. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpstreamHealthCheckActiveUnhealthy { #[serde(default = "default_unhealthy_http_statuses")] + #[schemars(length(min = 1), inner(range(min = 200, max = 599)))] pub http_statuses: Vec, #[serde(default = "default_http_failures")] + #[schemars(range(min = 1, max = 254))] pub http_failures: u32, #[serde(default = "default_tcp_failures")] + #[schemars(range(min = 1, max = 254))] pub tcp_failures: u32, #[serde(default = "default_timeouts")] + #[schemars(range(min = 1, max = 254))] pub timeouts: u32, #[serde(default = "default_interval")] + #[schemars(range(min = 1))] pub interval: u32, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpstreamHealthCheckActive { #[serde(rename = "type", default)] @@ -193,14 +211,17 @@ pub struct UpstreamHealthCheckActive { #[serde(default = "default_concurrency")] pub concurrency: i64, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub host: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] pub port: Option, #[serde(default = "default_http_path")] pub http_path: String, #[serde(default = "default_true")] pub https_verify_certificate: bool, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub http_request_headers: Option>, // The wrapping object itself is only `.optional()` (no `.default()`), so // absence stays `None` — only fields *inside* it (once present) default. @@ -214,7 +235,7 @@ fn default_true() -> bool { true } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpstreamHealthCheckPassive { #[serde(rename = "type", default)] @@ -228,7 +249,7 @@ pub struct UpstreamHealthCheckPassive { /// Health-check configuration: `active` (the gateway polls targets) is /// required whenever `checks` is set at all; `passive` (inferred from live /// traffic) is optional on top of it. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpstreamHealthCheck { pub active: UpstreamHealthCheckActive, @@ -236,18 +257,21 @@ pub struct UpstreamHealthCheck { pub passive: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpstreamKeepalivePool { #[serde(default = "default_keepalive_pool_size")] + #[schemars(range(min = 1))] pub size: u32, #[serde(default = "default_keepalive_idle_timeout")] + #[schemars(range(min = 0))] pub idle_timeout: f64, #[serde(default = "default_keepalive_requests")] + #[schemars(range(min = 1))] pub requests: u32, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct UpstreamTls { #[serde(skip_serializing_if = "Option::is_none")] @@ -265,14 +289,17 @@ pub struct UpstreamTls { /// appears: a service's own default `upstream` doesn't need one, while a /// named entry in `upstreams[]` does. That's a semantic rule for the /// validation layer, not a structural one. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Upstream { #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 256), regex(pattern = r"^[a-zA-Z0-9-_.]+$"))] pub id: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 65536))] pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 65536))] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option, @@ -290,8 +317,10 @@ pub struct Upstream { #[serde(default)] pub scheme: UpstreamScheme, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 65535))] pub retries: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0))] pub retry_timeout: Option, #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, @@ -304,6 +333,7 @@ pub struct Upstream { #[serde(default)] pub pass_host: UpstreamPassHost, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1))] pub upstream_host: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/rust/crates/adc-sdk/src/value_diff.rs b/rust/crates/adc-sdk/src/value_diff.rs index 53305bb2..e01d6e46 100644 --- a/rust/crates/adc-sdk/src/value_diff.rs +++ b/rust/crates/adc-sdk/src/value_diff.rs @@ -24,6 +24,30 @@ pub enum PathSegment { pub type DiffPath = Vec; +impl std::fmt::Display for PathSegment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PathSegment::Key(key) => write!(f, "{key}"), + PathSegment::Index(index) => write!(f, "[{index}]"), + } + } +} + +/// Renders a `DiffPath` as `services[0].upstream.nodes`: `Key` segments are +/// dot-joined, `Index` segments bracket directly onto the segment before +/// them. Not a `Display` impl on `DiffPath` itself — that's a bare `Vec` +/// alias, and a free function reads more clearly than an impl on it. +pub fn format_path(path: &[PathSegment]) -> String { + let mut out = String::new(); + for (i, segment) in path.iter().enumerate() { + if i > 0 && matches!(segment, PathSegment::Key(_)) { + out.push('.'); + } + out.push_str(&segment.to_string()); + } + out +} + /// A single field-level change, tagged by kind: new, deleted, edited, or an /// array-tail change. #[derive(Debug, Clone, PartialEq, Serialize)] diff --git a/rust/crates/adc-sdk/tests/schema_json.rs b/rust/crates/adc-sdk/tests/schema_json.rs new file mode 100644 index 00000000..2fc2969e --- /dev/null +++ b/rust/crates/adc-sdk/tests/schema_json.rs @@ -0,0 +1,26 @@ +//! Drift guard for `rust/schema.json`: fails if the committed export no +//! longer matches what `schemars` currently derives from +//! `resources::Configuration` — the same role as the TS SDK's own +//! `schema-json.spec.ts` ("should check schema.json is consistent with git +//! HEAD"). Doesn't regenerate the file; a red test here means someone +//! changed a `#[schemars(...)]`-relevant field and forgot to re-run +//! `cargo run -p adc-sdk --bin export-schema` and commit the result. + +#[test] +fn schema_json_is_consistent_with_the_current_resource_model() { + let current = schemars::schema_for!(adc_sdk::resources::Configuration); + let current_json = serde_json::to_string_pretty(¤t).expect("schema serializes to JSON") + "\n"; + + let committed = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../schema.json")) + .expect("rust/schema.json should exist — run `cargo run -p adc-sdk --bin export-schema`"); + + // Normalize CRLF -> LF on both sides: `current_json` is always built + // with bare `\n`, but a CRLF checkout (e.g. git's `core.autocrlf` on + // Windows) would read `committed` back with `\r\n`, which isn't the + // drift this test exists to catch. + assert_eq!( + current_json.replace("\r\n", "\n"), + committed.replace("\r\n", "\n"), + "rust/schema.json is stale — re-run `cargo run -p adc-sdk --bin export-schema` from rust/ and commit the result" + ); +} diff --git a/rust/schema.json b/rust/schema.json new file mode 100644 index 00000000..940c44a9 --- /dev/null +++ b/rust/schema.json @@ -0,0 +1,1354 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Configuration", + "description": "The external, user-facing declarative config file shape: nested\nsub-resources embedded under their parent, no top-level\nroutes/upstreams/consumer_credentials.", + "type": "object", + "properties": { + "services": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/Service" + } + }, + "ssls": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/SSL" + } + }, + "consumers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/Consumer" + } + }, + "consumer_groups": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/ConsumerGroup" + } + }, + "global_rules": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "plugin_metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + }, + "additionalProperties": false, + "$defs": { + "Service": { + "description": "Plain-shape deserialization target for `Service` — see `ServiceRoutes`'s\ndoc comment for why this indirection exists. Every field here must stay\nin sync with `Service`'s (routes/stream_routes excepted).\n\nThis is also where the `#[schemars(...)]` field attributes live rather\nthan on `Service` itself: `#[serde(try_from = \"ServiceRaw\")]` makes\n`schemars` derive `Service`'s exported schema *from* `ServiceRaw`'s shape\n(the actual wire input), not `Service`'s own internal representation —\nputting the attributes on `Service` would silently be dead code.", + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9-_.]+$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 65536 + }, + "labels": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/$defs/LabelValue" + } + }, + "upstream": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/Upstream" + }, + { + "not": { + "required": [ + "id" + ] + } + } + ] + }, + { + "type": "null" + } + ], + "default": null + }, + "upstreams": { + "anyOf": [ + { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/$defs/Upstream" + }, + { + "required": [ + "name" + ] + } + ] + } + }, + { + "type": "null" + } + ], + "default": null + }, + "plugins": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "path_prefix": { + "type": [ + "string", + "null" + ] + }, + "strip_path_prefix": { + "type": [ + "boolean", + "null" + ] + }, + "hosts": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1 + } + }, + "routes": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/Route" + }, + "default": null + }, + "stream_routes": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/StreamRoute" + }, + "default": null + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + }, + "LabelValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "Upstream": { + "description": "An upstream target group. `id` is always present on the struct as an\n`Option`, but whether it's actually required depends on where the upstream\nappears: a service's own default `upstream` doesn't need one, while a\nnamed entry in `upstreams[]` does. That's a semantic rule for the\nvalidation layer, not a structural one.", + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9-_.]+$" + }, + "name": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 65536 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 65536 + }, + "labels": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/$defs/LabelValue" + } + }, + "type": { + "$ref": "#/$defs/UpstreamBalancer", + "default": "roundrobin" + }, + "hash_on": { + "type": [ + "string", + "null" + ] + }, + "key": { + "type": [ + "string", + "null" + ] + }, + "checks": { + "anyOf": [ + { + "$ref": "#/$defs/UpstreamHealthCheck" + }, + { + "type": "null" + } + ] + }, + "nodes": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/UpstreamNode" + } + }, + "scheme": { + "$ref": "#/$defs/UpstreamScheme", + "default": "http" + }, + "retries": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0, + "maximum": 65535 + }, + "retry_timeout": { + "type": [ + "number", + "null" + ], + "format": "double", + "minimum": 0 + }, + "timeout": { + "anyOf": [ + { + "$ref": "#/$defs/Timeout" + }, + { + "type": "null" + } + ] + }, + "tls": { + "anyOf": [ + { + "$ref": "#/$defs/UpstreamTls" + }, + { + "type": "null" + } + ] + }, + "keepalive_pool": { + "anyOf": [ + { + "$ref": "#/$defs/UpstreamKeepalivePool" + }, + { + "type": "null" + } + ] + }, + "pass_host": { + "$ref": "#/$defs/UpstreamPassHost", + "default": "pass" + }, + "upstream_host": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "service_name": { + "type": [ + "string", + "null" + ] + }, + "discovery_type": { + "type": [ + "string", + "null" + ] + }, + "discovery_args": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "UpstreamBalancer": { + "type": "string", + "enum": [ + "roundrobin", + "chash", + "least_conn", + "ewma" + ] + }, + "UpstreamHealthCheck": { + "description": "Health-check configuration: `active` (the gateway polls targets) is\nrequired whenever `checks` is set at all; `passive` (inferred from live\ntraffic) is optional on top of it.", + "type": "object", + "properties": { + "active": { + "$ref": "#/$defs/UpstreamHealthCheckActive" + }, + "passive": { + "anyOf": [ + { + "$ref": "#/$defs/UpstreamHealthCheckPassive" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "required": [ + "active" + ] + }, + "UpstreamHealthCheckActive": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/UpstreamHealthCheckType", + "default": "http" + }, + "timeout": { + "type": "number", + "format": "double", + "default": 1.0 + }, + "concurrency": { + "type": "integer", + "format": "int64", + "default": 10 + }, + "host": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "port": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 1, + "maximum": 65535 + }, + "http_path": { + "type": "string", + "default": "/" + }, + "https_verify_certificate": { + "type": "boolean", + "default": true + }, + "http_request_headers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "minItems": 1 + }, + "healthy": { + "anyOf": [ + { + "$ref": "#/$defs/UpstreamHealthCheckActiveHealthy" + }, + { + "type": "null" + } + ] + }, + "unhealthy": { + "anyOf": [ + { + "$ref": "#/$defs/UpstreamHealthCheckActiveUnhealthy" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "UpstreamHealthCheckType": { + "type": "string", + "enum": [ + "http", + "https", + "tcp" + ] + }, + "UpstreamHealthCheckActiveHealthy": { + "description": "Active-health-check thresholds for marking a target healthy, plus the\npolling interval (seconds, default 1) active checks need that passive\nchecks don't.", + "type": "object", + "properties": { + "http_statuses": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 200, + "maximum": 599 + }, + "minItems": 1, + "default": [ + 200, + 302 + ] + }, + "successes": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "maximum": 254, + "default": 2 + }, + "interval": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "default": 1 + } + }, + "additionalProperties": false + }, + "UpstreamHealthCheckActiveUnhealthy": { + "description": "Active-health-check thresholds for marking a target unhealthy, plus the\npolling interval (seconds, default 1) active checks need that passive\nchecks don't.", + "type": "object", + "properties": { + "http_statuses": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 200, + "maximum": 599 + }, + "minItems": 1, + "default": [ + 429, + 404, + 500, + 501, + 502, + 503, + 504, + 505 + ] + }, + "http_failures": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "maximum": 254, + "default": 5 + }, + "tcp_failures": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "maximum": 254, + "default": 2 + }, + "timeouts": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "maximum": 254, + "default": 3 + }, + "interval": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "default": 1 + } + }, + "additionalProperties": false + }, + "UpstreamHealthCheckPassive": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/UpstreamHealthCheckType", + "default": "http" + }, + "healthy": { + "anyOf": [ + { + "$ref": "#/$defs/UpstreamHealthCheckPassiveHealthy" + }, + { + "type": "null" + } + ] + }, + "unhealthy": { + "anyOf": [ + { + "$ref": "#/$defs/UpstreamHealthCheckPassiveUnhealthy" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "UpstreamHealthCheckPassiveHealthy": { + "description": "Passive-health-check thresholds for marking a target healthy.", + "type": "object", + "properties": { + "http_statuses": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 200, + "maximum": 599 + }, + "minItems": 1, + "default": [ + 200, + 302 + ] + }, + "successes": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "maximum": 254, + "default": 2 + } + }, + "additionalProperties": false + }, + "UpstreamHealthCheckPassiveUnhealthy": { + "description": "Passive-health-check thresholds for marking a target unhealthy.", + "type": "object", + "properties": { + "http_statuses": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 200, + "maximum": 599 + }, + "minItems": 1, + "default": [ + 429, + 404, + 500, + 501, + 502, + 503, + 504, + 505 + ] + }, + "http_failures": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "maximum": 254, + "default": 5 + }, + "tcp_failures": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "maximum": 254, + "default": 2 + }, + "timeouts": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "maximum": 254, + "default": 3 + } + }, + "additionalProperties": false + }, + "UpstreamNode": { + "description": "A single upstream target: host, port, and load-balancing weight.", + "type": "object", + "properties": { + "host": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "integer", + "format": "uint16", + "minimum": 1, + "maximum": 65535 + }, + "weight": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "priority": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "metadata": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + } + }, + "additionalProperties": false, + "required": [ + "host", + "port", + "weight" + ] + }, + "UpstreamScheme": { + "type": "string", + "enum": [ + "grpc", + "grpcs", + "http", + "https", + "tcp", + "tls", + "udp", + "kafka" + ] + }, + "Timeout": { + "description": "Connect/send/read timeouts in seconds, shared by upstream and route configs.", + "type": "object", + "properties": { + "connect": { + "type": "number", + "exclusiveMinimum": 0 + }, + "send": { + "type": "number", + "exclusiveMinimum": 0 + }, + "read": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "connect", + "send", + "read" + ] + }, + "UpstreamTls": { + "type": "object", + "properties": { + "client_cert": { + "type": [ + "string", + "null" + ] + }, + "client_key": { + "type": [ + "string", + "null" + ] + }, + "client_cert_id": { + "type": [ + "string", + "null" + ] + }, + "verify": { + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + }, + "UpstreamKeepalivePool": { + "type": "object", + "properties": { + "size": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "default": 320 + }, + "idle_timeout": { + "type": "number", + "format": "double", + "minimum": 0, + "default": 60.0 + }, + "requests": { + "type": "integer", + "format": "uint32", + "minimum": 1, + "default": 1000 + } + }, + "additionalProperties": false + }, + "UpstreamPassHost": { + "type": "string", + "enum": [ + "pass", + "node", + "rewrite" + ] + }, + "Route": { + "description": "An HTTP route: matches requests by URI/host/method and applies plugins to them.", + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9-_.]+$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 65536 + }, + "labels": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/$defs/LabelValue" + } + }, + "hosts": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1 + } + }, + "uris": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "priority": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "timeout": { + "anyOf": [ + { + "$ref": "#/$defs/Timeout" + }, + { + "type": "null" + } + ] + }, + "vars": { + "type": [ + "array", + "null" + ], + "items": true + }, + "methods": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/HttpMethod" + }, + "minItems": 1 + }, + "enable_websocket": { + "type": [ + "boolean", + "null" + ] + }, + "remote_addrs": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "format": "ipv4", + "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$" + }, + { + "type": "string", + "format": "ipv6", + "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$" + }, + { + "type": "string", + "format": "cidrv4", + "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\/([0-9]|[1-2][0-9]|3[0-2])$" + }, + { + "type": "string", + "format": "cidrv6", + "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$" + } + ] + } + }, + { + "type": "null" + } + ] + }, + "plugins": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "filter_func": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "required": [ + "name", + "uris" + ] + }, + "HttpMethod": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + "CONNECT", + "TRACE", + "PURGE" + ] + }, + "StreamRoute": { + "description": "A stream (TCP/UDP/TLS) route: matches connections by address/SNI rather than URI.", + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9-_.]+$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 65536 + }, + "labels": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/$defs/LabelValue" + } + }, + "plugins": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "remote_addr": { + "type": [ + "string", + "null" + ] + }, + "server_addr": { + "type": [ + "string", + "null" + ] + }, + "server_port": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 1, + "maximum": 65535 + }, + "sni": { + "type": [ + "string", + "null" + ], + "minLength": 1 + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + }, + "SSL": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9-_.]+$" + }, + "labels": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/$defs/LabelValue" + } + }, + "type": { + "$ref": "#/$defs/SslType", + "default": "server" + }, + "snis": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "certificates": { + "type": "array", + "items": { + "$ref": "#/$defs/SSLCertificate" + }, + "minItems": 1 + }, + "client": { + "anyOf": [ + { + "$ref": "#/$defs/SslClient" + }, + { + "type": "null" + } + ] + }, + "ssl_protocols": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/SslProtocol" + }, + "minItems": 1 + } + }, + "additionalProperties": false, + "required": [ + "snis", + "certificates" + ] + }, + "SslType": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "SSLCertificate": { + "description": "A certificate/key pair. Either may also be a `$secret://`/`$env://`\nreference string instead of inline PEM content — that's a semantic\n(regex-union) check, not a structural one, so both fields are plain\n`String` here.", + "type": "object", + "properties": { + "certificate": { + "anyOf": [ + { + "type": "string", + "minLength": 128, + "maxLength": 65536 + }, + { + "type": "string", + "pattern": "^\\$(secret|env):\\/\\/" + } + ] + }, + "key": { + "anyOf": [ + { + "type": "string", + "minLength": 32, + "maxLength": 65536 + }, + { + "type": "string", + "pattern": "^\\$(secret|env):\\/\\/" + } + ] + } + }, + "additionalProperties": false, + "required": [ + "certificate", + "key" + ] + }, + "SslClient": { + "type": "object", + "properties": { + "ca": { + "anyOf": [ + { + "type": "string", + "minLength": 128, + "maxLength": 65536 + }, + { + "type": "string", + "pattern": "^\\$(secret|env):\\/\\/" + } + ] + }, + "depth": { + "type": "integer", + "format": "uint32", + "minimum": 0, + "default": 1 + }, + "skip_mtls_uri_regex": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "additionalProperties": false, + "required": [ + "ca" + ] + }, + "SslProtocol": { + "type": "string", + "enum": [ + "TLSv1.1", + "TLSv1.2", + "TLSv1.3" + ] + }, + "Consumer": { + "description": "A consumer, identified by `username` rather than `name`.", + "type": "object", + "properties": { + "username": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 65536 + }, + "labels": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/$defs/LabelValue" + } + }, + "plugins": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "credentials": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/ConsumerCredential" + } + } + }, + "additionalProperties": false, + "required": [ + "username" + ] + }, + "ConsumerCredential": { + "description": "A credential attached to a consumer (e.g. an API key or JWT secret).\n`type` is kept as a plain string rather than a closed enum: the 4-value\nrestriction (\"key-auth\"/\"basic-auth\"/\"jwt-auth\"/\"hmac-auth\") is a semantic\nrule for the validation layer, not encoded here.", + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9-_.]+$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 65536 + }, + "labels": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/$defs/LabelValue" + } + }, + "type": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false, + "required": [ + "name", + "type", + "config" + ] + }, + "ConsumerGroup": { + "description": "A named group of consumers sharing plugin configuration.", + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9-_.]+$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 65536 + }, + "labels": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/$defs/LabelValue" + } + }, + "plugins": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "consumers": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/Consumer" + } + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + } + } +}