diff --git a/.github/workflows/docker-publish-image.yml b/.github/workflows/docker-publish-image.yml new file mode 100644 index 0000000..5b4228b --- /dev/null +++ b/.github/workflows/docker-publish-image.yml @@ -0,0 +1,54 @@ +name: Publish Docker Image + +on: + push: + tags: + - '*' + +jobs: + build-dockerhub: + runs-on: ubuntu-latest + name: Build and Publish to DockerHub + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + flagsmith/edge-proxy-rs + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=branch + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + id: build + with: + push: true + platforms: linux/amd64,linux/arm64 + file: Dockerfile + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Image digest + run: echo ${{ steps.build.outputs.digest }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..9b3029e --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,81 @@ +name: Pull Request + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + fmt: + runs-on: ubuntu-latest + name: Format Check + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + runs-on: ubuntu-latest + name: Clippy + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-clippy- + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + test: + runs-on: ubuntu-latest + name: Unit Tests + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-test- + + - name: Run tests + run: cargo test --all-features diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e27db65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target/ +benchmark_results_*.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..869bdf6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files + args: ['--maxkb=500'] + + - repo: local + hooks: + - id: cargo-fmt + name: cargo fmt + entry: cargo fmt --all -- + language: system + types: [rust] + pass_filenames: false + + - id: cargo-clippy + name: cargo clippy + entry: cargo clippy --all-targets --all-features -- -D warnings + language: system + types: [rust] + pass_filenames: false + + - id: cargo-test + name: cargo test + entry: cargo test + language: system + types: [rust] + pass_filenames: false + stages: [pre-push] + + - id: cargo-check + name: cargo check + entry: cargo check --all-targets + language: system + types: [rust] + pass_filenames: false diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5a5cf7a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3217 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-compression" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "axum-test" +version = "18.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d419a2aae56fdf2bca28b274fd3f57dbc5cb8f2143c1c8629c82dbc75992596" +dependencies = [ + "anyhow", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "deranged" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "edge-proxy" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "axum-test", + "chrono", + "flagsmith-flag-engine", + "http", + "lru", + "mockall", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-test", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "validator", + "wiremock", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "expect-json" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422e7906e79941e5ac58c64dfd2da03e6ae3de62227f87606fbbe125d91080f9" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "regex", + "serde", + "serde_json", + "thiserror 2.0.17", + "typetag", + "uuid 1.19.0", +] + +[[package]] +name = "expect-json-macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bf7f5979e98460a0eb412665514594f68f366a32b85fa8d7ffb65bb1edee6a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "flagsmith-flag-engine" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3f7915c7365497f69816a41948ca23fa24f131fcf7c0c1b5aa2200fd57ccb4" +dependencies = [ + "chrono", + "md-5", + "num-bigint", + "num-traits", + "regex", + "semver", + "serde", + "serde_json", + "serde_json_path", + "sha2", + "uuid 0.8.2", +] + +[[package]] +name = "flate2" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + +[[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.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "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", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockall" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl-probe" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "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 = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror 2.0.17", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand", + "thiserror 2.0.17", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "751e04a496ca00bb97a5e043158d23d66b5aabf2e1d5aa2a0aaebb1aafe6f82c" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_json_path" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b992cea3194eea663ba99a042d61cea4bd1872da37021af56f6a37e0359b9d33" +dependencies = [ + "inventory", + "nom", + "regex", + "serde", + "serde_json", + "serde_json_path_core", + "serde_json_path_macros", + "thiserror 2.0.17", +] + +[[package]] +name = "serde_json_path_core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde67d8dfe7d4967b5a95e247d4148368ddd1e753e500adb34b3ffe40c6bc1bc" +dependencies = [ + "inventory", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "serde_json_path_macros" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517acfa7f77ddaf5c43d5f119c44a683774e130b4247b7d3210f8924506cfac8" +dependencies = [ + "inventory", + "serde_json_path_core", + "serde_json_path_macros_internal", +] + +[[package]] +name = "serde_json_path_macros_internal" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafbefbe175fa9bf03ca83ef89beecff7d2a95aaacd5732325b90ac8c3bd7b90" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.16", + "serde", +] + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..41d9ffa --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "edge-proxy" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "BSD-3-Clause" + +[dependencies] +axum = { version = "0.8", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["compression-gzip", "cors", "trace"] } +reqwest = { version = "0.13", features = ["json", "gzip"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +thiserror = "2" +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +async-trait = "0.1" +lru = "0.16" +http = "1" +validator = { version = "0.20", features = ["derive"] } +flagsmith-flag-engine = "0.5" + +[dev-dependencies] +axum-test = "18" +mockall = "0.14" +tokio-test = "0.4" +wiremock = "0.6" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a84824 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Build stage +FROM rust:1.85-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +# Copy manifests first for better caching +COPY Cargo.toml Cargo.lock ./ + +# Create dummy src to cache dependencies +RUN mkdir src && echo "fn main() {}" > src/main.rs +RUN cargo build --release && rm -rf src + +# Copy actual source files +COPY src ./src + +# Build the binary +RUN cargo build --release --bin edge-proxy + +# Runtime stage +FROM debian:bookworm-slim + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +# Copy binary from builder +COPY --from=builder /app/target/release/edge-proxy /usr/local/bin/edge-proxy + +EXPOSE 8000 + +USER nobody + +CMD ["edge-proxy"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..79930a3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +BSD 3-Clause License + +Copyright 2025 Bullet Train Ltd. A UK company. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f268629 --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +[![Feature Flag, Remote Config and A/B Testing platform, Flagsmith](https://raw.githubusercontent.com/Flagsmith/flagsmith/main/static-files/hero.png)](https://www.flagsmith.com/) + +[![Join the Discord chat](https://img.shields.io/discord/517647859495993347)](https://discord.gg/hFhxNtXzgm) + +[Flagsmith](https://flagsmith.com/) is an open source, fully featured, Feature Flag and Remote Config service. Use our +hosted API, deploy to your own private cloud, or run on-premise. + +# Edge Proxy (Rust) + +A high-performance Rust implementation of the Flagsmith Edge Proxy, built with Axum. This implementation is **6-10x faster** than the Python version while maintaining full API compatibility. + +The Flagsmith Edge Proxy allows you to run an instance of the Flagsmith Engine close to your servers. If you are running +Flagsmith within a server-side environment and you want to have very low latency flags, you have two options: + +1. Run the Edge Proxy and connect to it from your server-side SDKs +2. Run your server-side SDKs in [Local Evaluation Mode](https://docs.flagsmith.com/clients/overview#2---local-evaluation). + +The main benefit to running the Edge Proxy is that you reduce your polling requests against the Flagsmith API itself. + +## Performance + +Benchmarked with 50 features, 1 CPU, 512MB RAM: + +| Endpoint | Python | Rust | Speedup | +|----------|--------|------|---------| +| `GET /api/v1/flags` | 1,569 req/s | 10,075 req/s | **6.4x** | +| `POST /api/v1/identities` | 1,316 req/s | 14,110 req/s | **10.7x** | +| `GET /api/v1/environment-document` | 5,091 req/s | 17,904 req/s | **3.5x** | + +## Quick Start + +### Using Docker + +```bash +# Pull the image +docker pull flagsmith/edge-proxy-rs:latest + +# Run with mounted config +docker run -v ./config.json:/app/config.json -p 8000:8000 flagsmith/edge-proxy-rs:latest +``` + +### From Source + +```bash +# Build +cargo build --release + +# Create config.json (see Configuration section) + +# Run server +cargo run --release +``` + +## Local Development + +### Prerequisites + +- [Rust](https://rustup.rs/) 1.85+ +- [Docker](https://docs.docker.com/engine/install/) (optional) + +### Setup + +```bash +# Build +cargo build + +# Run tests +cargo test + +# Run with debug logging +RUST_LOG=debug cargo run +``` + +### Build and Run Docker Image Locally + +```bash +# Build image +docker build . -t edge-proxy-rs-local + +# Run image +docker run --rm -p 8000:8000 edge-proxy-rs-local +``` + +## Configuration + +Edge Proxy expects to load configuration from `./config.json`. + +### Configuration Options + +```json +{ + "environment_key_pairs": [ + { + "server_side_key": "ser.YOUR_SERVER_KEY", + "client_side_key": "YOUR_CLIENT_KEY" + } + ], + "api_url": "https://edge.api.flagsmith.com/api/v1", + "api_poll_frequency_seconds": 60, + "api_poll_timeout_seconds": 5, + "server": { + "host": "0.0.0.0", + "port": 8000 + }, + "endpoint_caches": { + "flags": { + "use_cache": false, + "cache_max_size": 128 + }, + "identities": { + "use_cache": false, + "cache_max_size": 128 + }, + "environment_document": { + "use_cache": true, + "cache_max_size": 128 + } + }, + "health_check": { + "environment_update_grace_period_seconds": 120 + } +} +``` + +### Custom Config Path + +```bash +export CONFIG_PATH=/path/to/config.json +cargo run +``` + +Or with Docker: + +```bash +docker run \ + -e CONFIG_PATH=/var/foo.json \ + -v ./config.json:/var/foo.json \ + flagsmith/edge-proxy-rs:latest +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `CONFIG_PATH` | Path to config file | `./config.json` | +| `RUST_LOG` | Log level (`error`, `warn`, `info`, `debug`, `trace`) | `info` | +| `TOKIO_WORKER_THREADS` | Number of async worker threads | CPU cores | + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/flags` | GET | Get all flags for environment | +| `/api/v1/flags?feature=name` | GET | Get specific flag | +| `/api/v1/identities/` | GET | Get flags for identity (query param) | +| `/api/v1/identities/` | POST | Get flags for identity (with traits) | +| `/api/v1/environment-document` | GET | Get full environment document (server key only) | +| `/health` | GET | Health check | +| `/proxy/health` | GET | Health check (alias) | +| `/proxy/health/readiness` | GET | Readiness probe | +| `/proxy/health/liveness` | GET | Liveness probe | + +## Load Testing + +```bash +# Test flags endpoint +wrk -t4 -c20 -d10s \ + -H 'X-Environment-Key: your_client_key' \ + http://localhost:8000/api/v1/flags + +# Test identities endpoint +wrk -t4 -c20 -d10s \ + -H 'X-Environment-Key: your_client_key' \ + -s post.lua \ + http://localhost:8000/api/v1/identities/ +``` + +## Architecture + +``` +src/ +├── main.rs # Entry point +├── lib.rs # Library exports +├── state.rs # AppState type +├── error.rs # Error types +├── config/ # Configuration +│ ├── settings.rs # AppSettings, key pairs +│ └── logging.rs # Logging setup +├── routes/ # HTTP layer +│ ├── mod.rs # Router setup +│ ├── flags.rs # GET /api/v1/flags +│ ├── identities.rs # GET/POST /api/v1/identities +│ ├── health.rs # Health checks +│ └── environment_document.rs +├── services/ # Business logic +│ └── environment.rs # Flag evaluation, polling +├── cache/ # Caching layer +│ ├── environment.rs # Environment document cache +│ └── endpoint.rs # LRU response cache +└── models/ # Domain models + ├── request.rs # IdentityWithTraits + └── response.rs # APIFeatureState +``` + +### Key Dependencies + +- **axum** - Web framework by Tokio team +- **tokio** - Async runtime +- **flagsmith-flag-engine** - Flag evaluation engine +- **reqwest** - HTTP client +- **serde** - Serialization +- **tower-http** - Middleware (CORS, compression, tracing) + +## Documentation + +See [Edge Proxy documentation](https://docs.flagsmith.com/advanced-use/edge-proxy). + diff --git a/src/cache/endpoint.rs b/src/cache/endpoint.rs new file mode 100644 index 0000000..536457a --- /dev/null +++ b/src/cache/endpoint.rs @@ -0,0 +1,330 @@ +use lru::LruCache; +use serde_json::Value; +use std::num::NonZeroUsize; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{debug, trace}; + +const DEFAULT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(128).unwrap(); + +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct CacheKey { + pub environment_key: String, + pub endpoint: String, + pub params: String, // Serialized query parameters or request body +} + +impl CacheKey { + pub fn new(environment_key: String, endpoint: String, params: String) -> Self { + Self { + environment_key, + endpoint, + params, + } + } +} + +type DocumentCache = Arc>>>; + +/// Thread-safe LRU cache for endpoint responses +pub struct EndpointCache { + flags_cache: Option>>>, + identities_cache: Option>>>, + /// Cache for pre-serialized environment document bytes (zero serialization per request) + environment_document_cache: Option, +} + +impl EndpointCache { + pub fn new( + flags_enabled: bool, + flags_size: usize, + identities_enabled: bool, + identities_size: usize, + environment_document_enabled: bool, + environment_document_size: usize, + ) -> Self { + let flags_cache = if flags_enabled && flags_size > 0 { + Some(Arc::new(RwLock::new(LruCache::new( + NonZeroUsize::new(flags_size).unwrap_or(DEFAULT_CACHE_SIZE), + )))) + } else { + None + }; + + let identities_cache = if identities_enabled && identities_size > 0 { + Some(Arc::new(RwLock::new(LruCache::new( + NonZeroUsize::new(identities_size).unwrap_or(DEFAULT_CACHE_SIZE), + )))) + } else { + None + }; + + let environment_document_cache = + if environment_document_enabled && environment_document_size > 0 { + Some(Arc::new(RwLock::new(LruCache::new( + NonZeroUsize::new(environment_document_size).unwrap_or(DEFAULT_CACHE_SIZE), + )))) + } else { + None + }; + + Self { + flags_cache, + identities_cache, + environment_document_cache, + } + } + + pub async fn get_flags(&self, key: &CacheKey) -> Option { + if let Some(cache) = &self.flags_cache { + let mut cache = cache.write().await; + let result = cache.get(key).cloned(); + if result.is_some() { + trace!("Flags cache hit for key: {:?}", key); + } else { + trace!("Flags cache miss for key: {:?}", key); + } + result + } else { + None + } + } + + pub async fn put_flags(&self, key: CacheKey, value: Value) { + if let Some(cache) = &self.flags_cache { + let mut cache = cache.write().await; + cache.put(key.clone(), value); + trace!("Cached flags response for key: {:?}", key); + } + } + + pub async fn get_identity(&self, key: &CacheKey) -> Option { + if let Some(cache) = &self.identities_cache { + let mut cache = cache.write().await; + let result = cache.get(key).cloned(); + if result.is_some() { + trace!("Identity cache hit for key: {:?}", key); + } else { + trace!("Identity cache miss for key: {:?}", key); + } + result + } else { + None + } + } + + pub async fn put_identity(&self, key: CacheKey, value: Value) { + if let Some(cache) = &self.identities_cache { + let mut cache = cache.write().await; + cache.put(key.clone(), value); + trace!("Cached identity response for key: {:?}", key); + } + } + + pub async fn get_environment_document(&self, key: &CacheKey) -> Option> { + if let Some(cache) = &self.environment_document_cache { + let mut cache = cache.write().await; + let result = cache.get(key).cloned(); + if result.is_some() { + trace!("Environment document cache hit for key: {:?}", key); + } else { + trace!("Environment document cache miss for key: {:?}", key); + } + result + } else { + None + } + } + + pub async fn put_environment_document(&self, key: CacheKey, bytes: Arc<[u8]>) { + if let Some(cache) = &self.environment_document_cache { + let mut cache = cache.write().await; + cache.put(key.clone(), bytes); + trace!("Cached environment document for key: {:?}", key); + } + } + + pub async fn clear_environment(&self, environment_key: &str) { + debug!( + "Clearing endpoint caches for environment: {}", + environment_key + ); + + if let Some(cache) = &self.flags_cache { + let mut cache = cache.write().await; + // Collect keys first - can't mutate while iterating LruCache + let keys_to_remove: Vec = cache + .iter() + .filter(|(k, _)| k.environment_key == environment_key) + .map(|(k, _)| k.clone()) + .collect(); + for key in keys_to_remove { + cache.pop(&key); + } + } + + if let Some(cache) = &self.identities_cache { + let mut cache = cache.write().await; + let keys_to_remove: Vec = cache + .iter() + .filter(|(k, _)| k.environment_key == environment_key) + .map(|(k, _)| k.clone()) + .collect(); + for key in keys_to_remove { + cache.pop(&key); + } + } + + if let Some(cache) = &self.environment_document_cache { + let mut cache = cache.write().await; + let keys_to_remove: Vec = cache + .iter() + .filter(|(k, _)| k.environment_key == environment_key) + .map(|(k, _)| k.clone()) + .collect(); + for key in keys_to_remove { + cache.pop(&key); + } + } + } + + pub async fn clear_all(&self) { + debug!("Clearing all endpoint caches"); + + if let Some(cache) = &self.flags_cache { + let mut cache = cache.write().await; + cache.clear(); + } + + if let Some(cache) = &self.identities_cache { + let mut cache = cache.write().await; + cache.clear(); + } + + if let Some(cache) = &self.environment_document_cache { + let mut cache = cache.write().await; + cache.clear(); + } + } + + pub fn is_flags_cache_enabled(&self) -> bool { + self.flags_cache.is_some() + } + + pub fn is_identities_cache_enabled(&self) -> bool { + self.identities_cache.is_some() + } + + pub fn is_environment_document_cache_enabled(&self) -> bool { + self.environment_document_cache.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_flags_cache_operations() { + // Given + let cache = EndpointCache::new(true, 2, false, 0, false, 0); + let key1 = CacheKey::new("env1".to_string(), "flags".to_string(), "".to_string()); + let key2 = CacheKey::new( + "env1".to_string(), + "flags".to_string(), + "feature=test".to_string(), + ); + let key3 = CacheKey::new("env2".to_string(), "flags".to_string(), "".to_string()); + let value1 = serde_json::json!({"flag1": true}); + let value2 = serde_json::json!({"flag2": false}); + let value3 = serde_json::json!({"flag3": true}); + + // When + cache.put_flags(key1.clone(), value1.clone()).await; + + // Then + assert_eq!(cache.get_flags(&key1).await, Some(value1.clone())); + + // When - fill cache to trigger LRU eviction (cache size is 2) + cache.put_flags(key2.clone(), value2.clone()).await; + cache.put_flags(key3.clone(), value3.clone()).await; + + // Then - key1 should be evicted + assert_eq!(cache.get_flags(&key1).await, None); + assert_eq!(cache.get_flags(&key2).await, Some(value2.clone())); + assert_eq!(cache.get_flags(&key3).await, Some(value3)); + } + + #[tokio::test] + async fn test_identity_cache_operations() { + // Given + let cache = EndpointCache::new(false, 0, true, 2, false, 0); + let key1 = CacheKey::new( + "env1".to_string(), + "identities".to_string(), + "user1".to_string(), + ); + let value1 = serde_json::json!({"identity": "user1", "flags": []}); + + // When + cache.put_identity(key1.clone(), value1.clone()).await; + + // Then + assert_eq!(cache.get_identity(&key1).await, Some(value1)); + } + + #[tokio::test] + async fn test_clear_environment() { + // Given + let cache = EndpointCache::new(true, 10, true, 10, false, 0); + let env1_key1 = CacheKey::new("env1".to_string(), "flags".to_string(), "".to_string()); + let env1_key2 = CacheKey::new( + "env1".to_string(), + "identities".to_string(), + "user1".to_string(), + ); + let env2_key1 = CacheKey::new("env2".to_string(), "flags".to_string(), "".to_string()); + let value = serde_json::json!({"test": true}); + + cache.put_flags(env1_key1.clone(), value.clone()).await; + cache.put_identity(env1_key2.clone(), value.clone()).await; + cache.put_flags(env2_key1.clone(), value.clone()).await; + + // When + cache.clear_environment("env1").await; + + // Then - env1 keys should be gone + assert_eq!(cache.get_flags(&env1_key1).await, None); + assert_eq!(cache.get_identity(&env1_key2).await, None); + + // Then - env2 keys should remain + assert_eq!(cache.get_flags(&env2_key1).await, Some(value)); + } + + #[tokio::test] + async fn test_disabled_caches() { + // Given + let cache = EndpointCache::new(false, 0, false, 0, false, 0); + + // Then + assert!(!cache.is_flags_cache_enabled()); + assert!(!cache.is_identities_cache_enabled()); + assert!(!cache.is_environment_document_cache_enabled()); + + // Given + let key = CacheKey::new("env1".to_string(), "flags".to_string(), "".to_string()); + let value = serde_json::json!({"test": true}); + + // When - operations should be no-ops + cache.put_flags(key.clone(), value.clone()).await; + + // Then + assert_eq!(cache.get_flags(&key).await, None); + + // When + cache.put_identity(key.clone(), value.clone()).await; + + // Then + assert_eq!(cache.get_identity(&key).await, None); + } +} diff --git a/src/cache/environment.rs b/src/cache/environment.rs new file mode 100644 index 0000000..a2cbed1 --- /dev/null +++ b/src/cache/environment.rs @@ -0,0 +1,107 @@ +use async_trait::async_trait; +use flagsmith_flag_engine::engine_eval::{EngineEvaluationContext, environment_to_context}; +use flagsmith_flag_engine::environments::Environment as FlagsmithEnvironment; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::models::engine::Environment; + +#[async_trait] +pub trait EnvironmentsCache: Send + Sync { + /// Get the raw environment document (for /environment-document endpoint) + /// Returns Arc to avoid cloning large JSON on every request + async fn get_environment(&self, environment_key: &str) -> Option>; + + /// Get the pre-computed evaluation context (for flag evaluation) + async fn get_context(&self, environment_key: &str) -> Option; + + /// Store environment document and compute context. Returns true if changed. + async fn put_environment(&self, environment_key: &str, document: Value) -> bool; + + /// Get identity override data + async fn get_identity(&self, environment_api_key: &str, identifier: &str) -> Option; +} + +#[derive(Clone, Default)] +pub struct LocalMemEnvironmentsCache { + /// Raw environment documents (for /environment-document endpoint) + /// Stored as Arc to avoid cloning large JSON on every request + environments: Arc>>>, + /// Pre-computed evaluation contexts (for flag evaluation) + contexts: Arc>>, + /// Identity overrides extracted from environments + identity_overrides: Arc>>>, +} + +impl LocalMemEnvironmentsCache { + pub fn new() -> Self { + Self { + environments: Arc::new(RwLock::new(HashMap::new())), + contexts: Arc::new(RwLock::new(HashMap::new())), + identity_overrides: Arc::new(RwLock::new(HashMap::new())), + } + } +} + +#[async_trait] +impl EnvironmentsCache for LocalMemEnvironmentsCache { + async fn get_environment(&self, environment_key: &str) -> Option> { + let environments = self.environments.read().await; + environments.get(environment_key).cloned() // Clones Arc (cheap), not Value + } + + async fn get_context(&self, environment_key: &str) -> Option { + let contexts = self.contexts.read().await; + contexts.get(environment_key).cloned() + } + + async fn put_environment(&self, environment_key: &str, document: Value) -> bool { + let mut environments = self.environments.write().await; + let mut contexts = self.contexts.write().await; + let mut identity_overrides = self.identity_overrides.write().await; + + // Check if document changed + let changed = environments + .get(environment_key) + .map(|existing| existing.as_ref() != &document) + .unwrap_or(true); + + if changed { + // Extract identity overrides + if let Some(overrides_array) = document + .get("identity_overrides") + .and_then(|v| v.as_array()) + { + let mut env_identities = HashMap::new(); + for override_obj in overrides_array { + if let Some(identifier) = + override_obj.get("identifier").and_then(|v| v.as_str()) + { + env_identities.insert(identifier.to_string(), override_obj.clone()); + } + } + identity_overrides.insert(environment_key.to_string(), env_identities); + } + + // Pre-compute the evaluation context + if let Ok(environment) = serde_json::from_value::(document.clone()) { + let flagsmith_env: FlagsmithEnvironment = environment.to_flagsmith_environment(); + let context = environment_to_context(flagsmith_env); + contexts.insert(environment_key.to_string(), context); + } + + environments.insert(environment_key.to_string(), Arc::new(document)); + } + + changed + } + + async fn get_identity(&self, environment_api_key: &str, identifier: &str) -> Option { + let identity_overrides = self.identity_overrides.read().await; + identity_overrides + .get(environment_api_key) + .and_then(|identities| identities.get(identifier).cloned()) + } +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..3d2e418 --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,5 @@ +pub mod endpoint; +pub mod environment; + +pub use endpoint::{CacheKey, EndpointCache}; +pub use environment::{EnvironmentsCache, LocalMemEnvironmentsCache}; diff --git a/src/config/logging.rs b/src/config/logging.rs new file mode 100644 index 0000000..cf2a20f --- /dev/null +++ b/src/config/logging.rs @@ -0,0 +1,33 @@ +use super::settings::LoggingSettings; +use tracing::Level; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +pub fn setup_logging(settings: &LoggingSettings) { + let log_level = match settings.log_level.to_uppercase().as_str() { + "CRITICAL" | "ERROR" => Level::ERROR, + "WARNING" | "WARN" => Level::WARN, + "INFO" => Level::INFO, + "DEBUG" => Level::DEBUG, + "TRACE" => Level::TRACE, + _ => Level::INFO, + }; + + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level.as_str())); + + let subscriber = tracing_subscriber::registry().with(env_filter); + + match settings.log_format.as_str() { + "json" => { + let fmt_layer = fmt::layer().json(); + subscriber.with(fmt_layer).init(); + } + _ => { + // "generic" format + let fmt_layer = fmt::layer() + .with_ansi(settings.use_colors) + .with_target(false); + subscriber.with(fmt_layer).init(); + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..908e7ff --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,4 @@ +pub mod logging; +pub mod settings; + +pub use settings::{AppSettings, EnvironmentKeyPair}; diff --git a/src/config/settings.rs b/src/config/settings.rs new file mode 100644 index 0000000..953744a --- /dev/null +++ b/src/config/settings.rs @@ -0,0 +1,266 @@ +use crate::error::{EdgeProxyError, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use validator::Validate; + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct EnvironmentKeyPair { + #[validate(length(min = 1))] + pub server_side_key: String, + #[validate(length(min = 1))] + pub client_side_key: String, +} + +impl EnvironmentKeyPair { + pub fn validate_server_key(&self) -> Result<()> { + if !self.server_side_key.starts_with("ser.") { + return Err(EdgeProxyError::ConfigurationError( + "server_side_key must start with 'ser.'".to_string(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerSettings { + #[serde(default = "default_host")] + pub host: String, + #[serde(default = "default_port")] + pub port: u16, +} + +fn default_host() -> String { + "0.0.0.0".to_string() +} + +fn default_port() -> u16 { + 8000 +} + +impl Default for ServerSettings { + fn default() -> Self { + Self { + host: default_host(), + port: default_port(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoggingSettings { + #[serde(default = "default_log_level")] + pub log_level: String, + #[serde(default = "default_log_format")] + pub log_format: String, + #[serde(default)] + pub use_colors: bool, +} + +fn default_log_level() -> String { + "INFO".to_string() +} + +fn default_log_format() -> String { + "generic".to_string() +} + +impl Default for LoggingSettings { + fn default() -> Self { + Self { + log_level: default_log_level(), + log_format: default_log_format(), + use_colors: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EndpointCacheSettings { + #[serde(default)] + pub use_cache: bool, + #[serde(default = "default_cache_size")] + pub cache_max_size: usize, +} + +fn default_cache_size() -> usize { + 128 +} + +impl Default for EndpointCacheSettings { + fn default() -> Self { + Self { + use_cache: false, + cache_max_size: default_cache_size(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EndpointCachesSettings { + #[serde(default)] + pub flags: EndpointCacheSettings, + #[serde(default)] + pub identities: EndpointCacheSettings, + #[serde(default)] + pub environment_document: EndpointCacheSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthCheckSettings { + pub environment_update_grace_period_seconds: Option, +} + +impl Default for HealthCheckSettings { + fn default() -> Self { + Self { + environment_update_grace_period_seconds: Some(120), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct AppSettings { + #[validate(nested)] + pub environment_key_pairs: Vec, + #[serde(default = "default_api_url")] + pub api_url: String, + #[serde(default = "default_api_poll_frequency")] + pub api_poll_frequency_seconds: u64, + #[serde(default = "default_api_poll_timeout")] + pub api_poll_timeout_seconds: u64, + #[serde(default)] + pub server: ServerSettings, + #[serde(default)] + pub logging: LoggingSettings, + #[serde(default)] + pub endpoint_caches: EndpointCachesSettings, + #[serde(default)] + pub health_check: HealthCheckSettings, +} + +fn default_api_url() -> String { + "https://edge.api.flagsmith.com/api/v1".to_string() +} + +fn default_api_poll_frequency() -> u64 { + 60 +} + +fn default_api_poll_timeout() -> u64 { + 5 +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + environment_key_pairs: vec![], + api_url: default_api_url(), + api_poll_frequency_seconds: default_api_poll_frequency(), + api_poll_timeout_seconds: default_api_poll_timeout(), + server: ServerSettings::default(), + logging: LoggingSettings::default(), + endpoint_caches: EndpointCachesSettings::default(), + health_check: HealthCheckSettings::default(), + } + } +} + +impl AppSettings { + pub fn new() -> Self { + Self::default() + } + + pub fn from_file(path: &PathBuf) -> Result { + let contents = fs::read_to_string(path).map_err(|e| { + EdgeProxyError::ConfigurationError(format!("Failed to read config file: {}", e)) + })?; + + let settings: AppSettings = serde_json::from_str(&contents).map_err(|e| { + EdgeProxyError::ConfigurationError(format!("Failed to parse config file: {}", e)) + })?; + + settings.validate().map_err(|e| { + EdgeProxyError::ConfigurationError(format!("Invalid configuration: {}", e)) + })?; + + for pair in &settings.environment_key_pairs { + pair.validate_server_key()?; + } + + Ok(settings) + } +} + +pub fn get_settings() -> Result { + let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string()); + let path = PathBuf::from(config_path); + + AppSettings::from_file(&path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_side_key_validation_valid() { + // Given + let pair = EnvironmentKeyPair { + server_side_key: "ser.456".to_string(), + client_side_key: "abc123".to_string(), + }; + + // When + let result = pair.validate_server_key(); + + // Then + assert!(result.is_ok()); + } + + #[test] + fn test_client_side_key_validation_invalid() { + // Given + let pair = EnvironmentKeyPair { + server_side_key: "456".to_string(), + client_side_key: "abc123".to_string(), + }; + + // When + let result = pair.validate_server_key(); + + // Then + assert!(result.is_err()); + } + + #[test] + fn test_client_side_key_validation_empty_server_key() { + // Given + let pair = EnvironmentKeyPair { + server_side_key: "".to_string(), + client_side_key: "abc123".to_string(), + }; + + // When + let result = pair.validate(); + + // Then + assert!(result.is_err()); + } + + #[test] + fn test_client_side_key_validation_empty_client_key() { + // Given + let pair = EnvironmentKeyPair { + server_side_key: "ser.456".to_string(), + client_side_key: "".to_string(), + }; + + // When + let result = pair.validate(); + + // Then + assert!(result.is_err()); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..0f059d7 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,64 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::json; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum EdgeProxyError { + #[error("unknown key '{0}'")] + FlagsmithUnknownKey(String), + + #[error("feature '{0}' not found")] + FeatureNotFound(String), + + #[error("service unavailable: {0}")] + ServiceUnavailable(String), + + #[error("configuration error: {0}")] + ConfigurationError(String), + + #[error("HTTP request failed: {0}")] + HttpError(#[from] reqwest::Error), + + #[error("JSON serialization error: {0}")] + SerializationError(#[from] serde_json::Error), +} + +impl IntoResponse for EdgeProxyError { + fn into_response(self) -> Response { + let (status, message) = match self { + EdgeProxyError::FlagsmithUnknownKey(key) => { + (StatusCode::UNAUTHORIZED, format!("unknown key '{}'", key)) + } + EdgeProxyError::FeatureNotFound(feature) => ( + StatusCode::NOT_FOUND, + format!("feature '{}' not found", feature), + ), + EdgeProxyError::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg), + EdgeProxyError::ConfigurationError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + EdgeProxyError::HttpError(e) => (StatusCode::BAD_GATEWAY, e.to_string()), + EdgeProxyError::SerializationError(e) => { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + } + }; + + let status_name = match status { + StatusCode::UNAUTHORIZED => "unauthorized", + StatusCode::NOT_FOUND => "not_found", + StatusCode::SERVICE_UNAVAILABLE => "service_unavailable", + _ => "error", + }; + + let body = Json(json!({ + "status": status_name, + "message": message, + })); + + (status, body).into_response() + } +} + +pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..01b9161 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod cache; +pub mod config; +pub mod error; +pub mod models; +pub mod routes; +pub mod services; +pub mod state; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..67c5ebd --- /dev/null +++ b/src/main.rs @@ -0,0 +1,47 @@ +use edge_proxy::config::logging::setup_logging; +use edge_proxy::config::settings::get_settings; +use edge_proxy::routes::create_router; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use tracing::info; + +const DEFAULT_HOST: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED); + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let settings = get_settings()?; + + setup_logging(&settings.logging); + + info!("Starting Edge Proxy server..."); + info!("API URL: {}", settings.api_url); + info!( + "Polling frequency: {}s", + settings.api_poll_frequency_seconds + ); + + let (app, environment_service) = create_router(settings.clone()); + + let polling_service = environment_service.clone(); + tokio::spawn(async move { + polling_service.poll_environments().await; + }); + + info!("Loading initial environment data..."); + environment_service.refresh_environment_caches().await; + + let addr = SocketAddr::from(( + settings + .server + .host + .parse::() + .unwrap_or(DEFAULT_HOST), + settings.server.port, + )); + + info!("Listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/src/models/engine.rs b/src/models/engine.rs new file mode 100644 index 0000000..8d1a072 --- /dev/null +++ b/src/models/engine.rs @@ -0,0 +1,59 @@ +// Wrapper types for flagsmith-flag-engine to add missing fields + +use flagsmith_flag_engine::environments::Environment as FlagsmithEnvironment; +use flagsmith_flag_engine::features::FeatureState; +use flagsmith_flag_engine::identities::Identity; +use flagsmith_flag_engine::organisations::Organisation; +use flagsmith_flag_engine::projects::Project as FlagsmithProject; +use flagsmith_flag_engine::segments::Segment; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Project { + pub id: u32, + pub name: String, + pub organisation: Organisation, + pub hide_disabled_flags: bool, + pub segments: Vec, + + #[serde(default)] + pub server_key_only_feature_ids: Vec, +} + +impl From for FlagsmithProject { + fn from(project: Project) -> Self { + FlagsmithProject { + id: project.id, + name: project.name, + organisation: project.organisation, + hide_disabled_flags: project.hide_disabled_flags, + segments: project.segments, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Environment { + pub id: u32, + pub api_key: String, + #[serde(default)] + pub name: String, + pub project: Project, + pub feature_states: Vec, + + #[serde(default)] + pub identity_overrides: Vec, +} + +impl Environment { + pub fn to_flagsmith_environment(&self) -> FlagsmithEnvironment { + FlagsmithEnvironment { + id: self.id, + api_key: self.api_key.clone(), + name: self.name.clone(), + project: self.project.clone().into(), + feature_states: self.feature_states.clone(), + identity_overrides: self.identity_overrides.clone(), + } + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..1da49b5 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,6 @@ +pub mod engine; +pub mod request; +pub mod response; + +pub use request::{IdentityWithTraits, TraitModel}; +pub use response::{APIFeature, APIFeatureState, IdentityResponse}; diff --git a/src/models/request.rs b/src/models/request.rs new file mode 100644 index 0000000..2bf01b7 --- /dev/null +++ b/src/models/request.rs @@ -0,0 +1,157 @@ +use flagsmith_flag_engine::identities::Trait as FlagsmithTrait; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fmt; +use std::hash::{Hash, Hasher}; +use validator::Validate; + +#[derive(Debug, Clone, Serialize, Deserialize, Validate, PartialEq, Eq)] +pub struct TraitModel { + pub trait_key: String, + pub trait_value: serde_json::Value, +} + +impl TraitModel { + pub fn to_response_json(&self) -> serde_json::Value { + json!({ + "trait_key": self.trait_key, + "trait_value": self.trait_value, + }) + } +} + +impl From<&TraitModel> for FlagsmithTrait { + fn from(t: &TraitModel) -> Self { + let flagsmith_value = serde_json::from_value(t.trait_value.clone()).unwrap_or_default(); + FlagsmithTrait { + trait_key: t.trait_key.clone(), + trait_value: flagsmith_value, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct IdentityWithTraits { + pub identifier: String, + #[serde(default)] + pub traits: Vec, +} + +impl IdentityWithTraits { + pub fn new(identifier: String) -> Self { + Self { + identifier, + traits: Vec::new(), + } + } + + pub fn with_traits(identifier: String, traits: Vec) -> Self { + Self { identifier, traits } + } +} + +impl fmt::Display for IdentityWithTraits { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "identifier:{}", self.identifier)?; + if !self.traits.is_empty() { + write!(f, "|traits:")?; + for (i, t) in self.traits.iter().enumerate() { + if i > 0 { + write!(f, ",")?; + } + write!(f, "{}=", t.trait_key)?; + match &t.trait_value { + serde_json::Value::String(s) => write!(f, "{}", s)?, + serde_json::Value::Number(n) => write!(f, "{}", n)?, + serde_json::Value::Bool(true) => write!(f, "True")?, + serde_json::Value::Bool(false) => write!(f, "False")?, + other => write!(f, "{}", other)?, + } + } + } + Ok(()) + } +} + +impl Hash for IdentityWithTraits { + fn hash(&self, state: &mut H) { + self.to_string().hash(state); + } +} + +impl PartialEq for IdentityWithTraits { + fn eq(&self, other: &Self) -> bool { + self.to_string() == other.to_string() + } +} + +impl Eq for IdentityWithTraits {} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::hash_map::DefaultHasher; + + #[test] + fn test_identity_with_traits_str() { + // Given + let identifier = "foo".to_string(); + let traits = vec![ + TraitModel { + trait_key: "foo".to_string(), + trait_value: json!("bar"), + }, + TraitModel { + trait_key: "age".to_string(), + trait_value: json!(21), + }, + TraitModel { + trait_key: "is_cool".to_string(), + trait_value: json!(true), + }, + ]; + + let expected = "identifier:foo|traits:foo=bar,age=21,is_cool=True"; + + // When + let identity_with_traits = IdentityWithTraits::with_traits(identifier, traits); + + // Then + assert_eq!(identity_with_traits.to_string(), expected); + } + + #[test] + fn test_identity_with_traits_hash() { + // Given + let identifier = "foo".to_string(); + let traits = vec![ + TraitModel { + trait_key: "foo".to_string(), + trait_value: json!("bar"), + }, + TraitModel { + trait_key: "age".to_string(), + trait_value: json!(21), + }, + TraitModel { + trait_key: "is_cool".to_string(), + trait_value: json!(true), + }, + ]; + + let expected_string = "identifier:foo|traits:foo=bar,age=21,is_cool=True"; + let mut hasher = DefaultHasher::new(); + expected_string.hash(&mut hasher); + let expected_hash = hasher.finish(); + + // When + let identity_with_traits = IdentityWithTraits::with_traits(identifier, traits); + let mut hasher2 = DefaultHasher::new(); + identity_with_traits.hash(&mut hasher2); + let actual_hash = hasher2.finish(); + + // Then + assert_eq!(actual_hash, expected_hash); + } +} diff --git a/src/models/response.rs b/src/models/response.rs new file mode 100644 index 0000000..9cf3434 --- /dev/null +++ b/src/models/response.rs @@ -0,0 +1,41 @@ +use flagsmith_flag_engine::engine_eval::FlagResult; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::borrow::Cow; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct APIFeature { + pub id: i64, + pub name: String, + #[serde(rename = "type")] + pub feature_type: Cow<'static, str>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct APIFeatureState { + pub enabled: bool, + pub feature: APIFeature, + pub feature_state_value: Value, +} + +impl From<&FlagResult> for APIFeatureState { + fn from(flag_result: &FlagResult) -> Self { + let json_value = serde_json::to_value(&flag_result.value).unwrap_or(Value::Null); + + APIFeatureState { + enabled: flag_result.enabled, + feature: APIFeature { + id: flag_result.metadata.feature_id as i64, + name: flag_result.name.clone(), + feature_type: Cow::Borrowed("STANDARD"), + }, + feature_state_value: json_value, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct IdentityResponse { + pub flags: Vec, + pub traits: Vec, +} diff --git a/src/routes/environment_document.rs b/src/routes/environment_document.rs new file mode 100644 index 0000000..7e68e3a --- /dev/null +++ b/src/routes/environment_document.rs @@ -0,0 +1,25 @@ +use crate::error::{EdgeProxyError, Result}; +use crate::routes::extractors::extract_environment_key; +use crate::state::AppState; +use axum::{ + extract::State, + http::{HeaderMap, header}, + response::IntoResponse, +}; + +pub async fn get_environment_document( + State(service): State, + headers: HeaderMap, +) -> Result { + let environment_key = extract_environment_key(&headers)?; + + // Verify it's a server key + if !environment_key.starts_with("ser.") { + return Err(EdgeProxyError::FlagsmithUnknownKey(environment_key)); + } + + // Get pre-serialized bytes (with endpoint caching if enabled) + let body = service.get_environment_bytes(&environment_key).await?; + + Ok(([(header::CONTENT_TYPE, "application/json")], body.to_vec())) +} diff --git a/src/routes/extractors.rs b/src/routes/extractors.rs new file mode 100644 index 0000000..de78f97 --- /dev/null +++ b/src/routes/extractors.rs @@ -0,0 +1,10 @@ +use crate::error::{EdgeProxyError, Result}; +use axum::http::HeaderMap; + +pub fn extract_environment_key(headers: &HeaderMap) -> Result { + headers + .get("X-Environment-Key") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .ok_or_else(|| EdgeProxyError::FlagsmithUnknownKey("".to_string())) +} diff --git a/src/routes/flags.rs b/src/routes/flags.rs new file mode 100644 index 0000000..136f868 --- /dev/null +++ b/src/routes/flags.rs @@ -0,0 +1,34 @@ +use crate::error::Result; +use crate::routes::extractors::extract_environment_key; +use crate::state::AppState; +use axum::{ + Json, + extract::{Query, State}, + http::HeaderMap, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct FlagsQuery { + pub feature: Option, +} + +pub async fn get_flags( + State(service): State, + headers: HeaderMap, + Query(query): Query, +) -> Result> { + let environment_key = extract_environment_key(&headers)?; + + let flags = service + .get_flags_response_data(&environment_key, query.feature.as_deref()) + .await?; + + if query.feature.is_some() && flags.len() == 1 { + // Return single feature + Ok(Json(serde_json::to_value(&flags[0])?)) + } else { + // Return all features + Ok(Json(serde_json::to_value(flags)?)) + } +} diff --git a/src/routes/health.rs b/src/routes/health.rs new file mode 100644 index 0000000..14738b8 --- /dev/null +++ b/src/routes/health.rs @@ -0,0 +1,69 @@ +use crate::state::AppState; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthCheckResponse { + pub status: String, + pub reason: Option, + pub last_successful_update: Option>, +} + +impl HealthCheckResponse { + pub fn ok(last_successful_update: Option>) -> Self { + Self { + status: "ok".to_string(), + reason: None, + last_successful_update, + } + } + + pub fn error(reason: String, last_successful_update: Option>) -> Self { + Self { + status: "error".to_string(), + reason: Some(reason), + last_successful_update, + } + } +} + +pub async fn health_check(State(service): State) -> impl IntoResponse { + let last_updated = service.last_updated_at.read().await; + + match *last_updated { + None => { + let response = HealthCheckResponse::error( + "environment document(s) not updated.".to_string(), + None, + ); + (StatusCode::SERVICE_UNAVAILABLE, Json(response)) + } + Some(last_update_time) => { + // Check if stale based on grace period + if let Some(grace_period) = service + .settings + .health_check + .environment_update_grace_period_seconds + { + let now = Utc::now(); + let elapsed = now.signed_duration_since(last_update_time); + + if elapsed.num_seconds() > grace_period as i64 { + let response = HealthCheckResponse::error( + "environment document(s) stale.".to_string(), + Some(last_update_time), + ); + return (StatusCode::SERVICE_UNAVAILABLE, Json(response)); + } + } + + let response = HealthCheckResponse::ok(Some(last_update_time)); + (StatusCode::OK, Json(response)) + } + } +} + +pub async fn liveness_check() -> impl IntoResponse { + StatusCode::OK +} diff --git a/src/routes/identities.rs b/src/routes/identities.rs new file mode 100644 index 0000000..2fabaaf --- /dev/null +++ b/src/routes/identities.rs @@ -0,0 +1,44 @@ +use crate::error::Result; +use crate::models::{IdentityResponse, IdentityWithTraits}; +use crate::routes::extractors::extract_environment_key; +use crate::state::AppState; +use axum::{ + Json, + extract::{Query, State}, + http::HeaderMap, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct IdentitiesQuery { + pub identifier: String, +} + +pub async fn get_identities( + State(service): State, + headers: HeaderMap, + Query(query): Query, +) -> Result> { + let environment_key = extract_environment_key(&headers)?; + + let identity = IdentityWithTraits::new(query.identifier); + let response = service + .get_identity_response_data(&identity, &environment_key) + .await?; + + Ok(Json(response)) +} + +pub async fn post_identities( + State(service): State, + headers: HeaderMap, + Json(identity): Json, +) -> Result> { + let environment_key = extract_environment_key(&headers)?; + + let response = service + .get_identity_response_data(&identity, &environment_key) + .await?; + + Ok(Json(response)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..c601d5c --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,45 @@ +pub mod environment_document; +pub mod extractors; +pub mod flags; +pub mod health; +pub mod identities; + +use crate::config::AppSettings; +use crate::services::EnvironmentService; +use axum::{ + Router, + routing::{get, post}, +}; +use std::sync::Arc; +use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer}; + +pub fn create_router(settings: AppSettings) -> (Router, Arc) { + let environment_service = Arc::new(EnvironmentService::new(settings)); + + let app = Router::new() + // Health check routes + .route("/health", get(health::health_check)) + .route("/proxy/health", get(health::health_check)) + .route("/proxy/health/readiness", get(health::health_check)) + .route("/proxy/health/liveness", get(health::liveness_check)) + // Flags routes (with and without trailing slash) + .route("/api/v1/flags", get(flags::get_flags)) + .route("/api/v1/flags/", get(flags::get_flags)) + // Identities routes (with and without trailing slash) + .route("/api/v1/identities", get(identities::get_identities)) + .route("/api/v1/identities/", get(identities::get_identities)) + .route("/api/v1/identities", post(identities::post_identities)) + .route("/api/v1/identities/", post(identities::post_identities)) + // Environment document route + .route( + "/api/v1/environment-document", + get(environment_document::get_environment_document), + ) + // Middleware layers + .layer(CompressionLayer::new()) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()) + .with_state(environment_service.clone()); + + (app, environment_service) +} diff --git a/src/services/environment.rs b/src/services/environment.rs new file mode 100644 index 0000000..f79aa86 --- /dev/null +++ b/src/services/environment.rs @@ -0,0 +1,398 @@ +use crate::cache::{CacheKey, EndpointCache, EnvironmentsCache, LocalMemEnvironmentsCache}; +use crate::config::settings::{AppSettings, EnvironmentKeyPair}; +use crate::error::{EdgeProxyError, Result}; +use crate::models::{APIFeatureState, IdentityResponse, IdentityWithTraits}; +use crate::services::feature_utils::filter_out_server_key_only_flag_results; +use chrono::{DateTime, Utc}; +use flagsmith_flag_engine::engine::get_evaluation_result; +use flagsmith_flag_engine::engine_eval::{FlagResult, add_identity_to_context}; +use flagsmith_flag_engine::identities::Trait as FlagsmithTrait; +use reqwest::Client; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; +use tracing::{debug, error, info}; + +pub struct EnvironmentService { + pub cache: Arc, + pub endpoint_cache: Arc, + client: Client, + pub settings: AppSettings, + pub last_updated_at: Arc>>>, + key_mapping: HashMap, // any_key -> server_key (for validation) + server_to_client: HashMap, // server_key -> client_key (for cache lookup) +} + +impl EnvironmentService { + pub fn new(settings: AppSettings) -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(settings.api_poll_timeout_seconds)) + .gzip(true) + .build() + .expect("Failed to create HTTP client"); + + let mut key_mapping = HashMap::new(); + let mut server_to_client = HashMap::new(); + for pair in &settings.environment_key_pairs { + key_mapping.insert(pair.client_side_key.clone(), pair.server_side_key.clone()); + // Also allow server keys to map to themselves + key_mapping.insert(pair.server_side_key.clone(), pair.server_side_key.clone()); + // Map server key to client key for cache lookup + server_to_client.insert(pair.server_side_key.clone(), pair.client_side_key.clone()); + } + + let endpoint_cache = Arc::new(EndpointCache::new( + settings.endpoint_caches.flags.use_cache, + settings.endpoint_caches.flags.cache_max_size, + settings.endpoint_caches.identities.use_cache, + settings.endpoint_caches.identities.cache_max_size, + settings.endpoint_caches.environment_document.use_cache, + settings.endpoint_caches.environment_document.cache_max_size, + )); + + Self { + cache: Arc::new(LocalMemEnvironmentsCache::new()), + endpoint_cache, + client, + settings, + last_updated_at: Arc::new(RwLock::new(None)), + key_mapping, + server_to_client, + } + } + + pub fn with_cache(settings: AppSettings, cache: Arc) -> Self { + let mut service = Self::new(settings); + service.cache = cache; + service + } + + pub async fn refresh_environment_caches(&self) -> bool { + let mut all_success = true; + + for pair in &self.settings.environment_key_pairs { + match self.fetch_environment(pair).await { + Ok(document) => { + let changed = self + .cache + .put_environment(&pair.client_side_key, document) + .await; + + if changed { + info!( + "Environment cache updated for key: {}", + pair.client_side_key + ); + self.endpoint_cache + .clear_environment(&pair.client_side_key) + .await; + self.endpoint_cache + .clear_environment(&pair.server_side_key) + .await; + } + } + Err(e) => { + error!( + "Failed to fetch environment for key {}: {}", + pair.server_side_key, e + ); + all_success = false; + } + } + } + + if all_success { + let mut last_updated = self.last_updated_at.write().await; + *last_updated = Some(Utc::now()); + } + + all_success + } + + async fn fetch_environment(&self, pair: &EnvironmentKeyPair) -> Result { + let url = format!("{}/environment-document/", self.settings.api_url); + + let if_modified_since = + if let Some(cached_doc) = self.cache.get_environment(&pair.client_side_key).await { + cached_doc + .get("updated_at") + .and_then(|v| v.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.to_rfc2822()) + } else { + None + }; + + let mut request = self + .client + .get(&url) + .header("X-Environment-Key", &pair.server_side_key); + + if let Some(if_modified_since_value) = if_modified_since { + request = request.header("If-Modified-Since", if_modified_since_value); + } + + let response = request.send().await?; + + if response.status() == reqwest::StatusCode::NOT_MODIFIED { + return self + .cache + .get_environment(&pair.client_side_key) + .await + .map(|arc| (*arc).clone()) + .ok_or_else(|| { + EdgeProxyError::ServiceUnavailable("Cache inconsistency".to_string()) + }); + } + + response.error_for_status_ref()?; + + let document: serde_json::Value = response.json().await?; + Ok(document) + } + + pub async fn get_environment(&self, environment_key: &str) -> Result> { + if !self.key_mapping.contains_key(environment_key) { + return Err(EdgeProxyError::FlagsmithUnknownKey( + environment_key.to_string(), + )); + } + + // Map server key to client key for cache lookup (cache stores by client key) + let client_key = self + .server_to_client + .get(environment_key) + .map(|s| s.as_str()) + .unwrap_or(environment_key); + + // Get from cache (returns Arc to avoid cloning) + self.cache + .get_environment(client_key) + .await + .ok_or_else(|| EdgeProxyError::ServiceUnavailable("Environment not loaded".to_string())) + } + + /// Get pre-serialized environment document bytes with endpoint caching + pub async fn get_environment_bytes(&self, environment_key: &str) -> Result> { + if self.endpoint_cache.is_environment_document_cache_enabled() { + let cache_key = CacheKey::new( + environment_key.to_string(), + "environment_document".to_string(), + "".to_string(), + ); + + if let Some(cached_bytes) = self + .endpoint_cache + .get_environment_document(&cache_key) + .await + { + return Ok(cached_bytes); + } + } + + let document = self.get_environment(environment_key).await?; + + let bytes: Arc<[u8]> = serde_json::to_vec(&*document)?.into(); + + if self.endpoint_cache.is_environment_document_cache_enabled() { + let cache_key = CacheKey::new( + environment_key.to_string(), + "environment_document".to_string(), + "".to_string(), + ); + self.endpoint_cache + .put_environment_document(cache_key, bytes.clone()) + .await; + } + + Ok(bytes) + } + + fn extract_server_key_only_ids(document: &serde_json::Value) -> Vec { + document + .get("project") + .and_then(|p| p.get("server_key_only_feature_ids")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_u64().map(|n| n as u32)) + .collect() + }) + .unwrap_or_default() + } + + pub async fn get_flags_response_data( + &self, + environment_key: &str, + feature_name: Option<&str>, + ) -> Result> { + if self.endpoint_cache.is_flags_cache_enabled() { + let cache_key = CacheKey::new( + environment_key.to_string(), + "flags".to_string(), + feature_name.unwrap_or("").to_string(), + ); + + if let Some(cached) = self.endpoint_cache.get_flags(&cache_key).await { + if let Ok(flags) = serde_json::from_value::>(cached) { + return Ok(flags); + } + } + } + + if !self.key_mapping.contains_key(environment_key) { + return Err(EdgeProxyError::FlagsmithUnknownKey( + environment_key.to_string(), + )); + } + + let context = self + .cache + .get_context(environment_key) + .await + .ok_or_else(|| { + EdgeProxyError::ServiceUnavailable("Environment not loaded".to_string()) + })?; + + let evaluation_result = get_evaluation_result(&context); + + let mut flag_results: Vec = evaluation_result.flags.into_values().collect(); + + if let Some(name) = feature_name { + flag_results.retain(|fr| fr.name == name); + if flag_results.is_empty() { + return Err(EdgeProxyError::FeatureNotFound(name.to_string())); + } + } + + if !environment_key.starts_with("ser.") { + if let Some(document) = self.cache.get_environment(environment_key).await { + let server_key_only_ids = Self::extract_server_key_only_ids(&document); + flag_results = + filter_out_server_key_only_flag_results(flag_results, &server_key_only_ids); + } + } + + // Check if specific feature was filtered out + if let Some(name) = feature_name { + if flag_results.is_empty() { + return Err(EdgeProxyError::FeatureNotFound(name.to_string())); + } + } + + let result: Vec = flag_results.iter().map(Into::into).collect(); + + // Cache the result if enabled + if self.endpoint_cache.is_flags_cache_enabled() { + let cache_key = CacheKey::new( + environment_key.to_string(), + "flags".to_string(), + feature_name.unwrap_or("").to_string(), + ); + + if let Ok(value) = serde_json::to_value(&result) { + self.endpoint_cache.put_flags(cache_key, value).await; + } + } + + Ok(result) + } + + pub async fn get_identity_response_data( + &self, + identity: &IdentityWithTraits, + environment_key: &str, + ) -> Result { + if self.endpoint_cache.is_identities_cache_enabled() { + // Create cache key from identity data + let cache_params = + serde_json::to_string(&identity).unwrap_or_else(|_| identity.identifier.clone()); + let cache_key = CacheKey::new( + environment_key.to_string(), + "identities".to_string(), + cache_params.clone(), + ); + + if let Some(cached) = self.endpoint_cache.get_identity(&cache_key).await { + if let Ok(response) = serde_json::from_value::(cached) { + return Ok(response); + } + } + } + + // Verify the key is valid + if !self.key_mapping.contains_key(environment_key) { + return Err(EdgeProxyError::FlagsmithUnknownKey( + environment_key.to_string(), + )); + } + + // Get pre-computed context from cache + let context = self + .cache + .get_context(environment_key) + .await + .ok_or_else(|| { + EdgeProxyError::ServiceUnavailable("Environment not loaded".to_string()) + })?; + + let flagsmith_traits: Vec = + identity.traits.iter().map(Into::into).collect(); + + let context_with_identity = + add_identity_to_context(&context, &identity.identifier, &flagsmith_traits); + + let evaluation_result = get_evaluation_result(&context_with_identity); + + let mut flag_results: Vec = evaluation_result.flags.into_values().collect(); + + // Filter out server-key-only features if using client key + if !environment_key.starts_with("ser.") { + // Get server_key_only_feature_ids from cached document + if let Some(document) = self.cache.get_environment(environment_key).await { + let server_key_only_ids = Self::extract_server_key_only_ids(&document); + flag_results = + filter_out_server_key_only_flag_results(flag_results, &server_key_only_ids); + } + } + + let result = IdentityResponse { + flags: flag_results.iter().map(Into::into).collect(), + traits: identity + .traits + .iter() + .map(|t| t.to_response_json()) + .collect(), + }; + + // Cache the result if enabled + if self.endpoint_cache.is_identities_cache_enabled() { + let cache_params = + serde_json::to_string(&identity).unwrap_or_else(|_| identity.identifier.clone()); + let cache_key = CacheKey::new( + environment_key.to_string(), + "identities".to_string(), + cache_params, + ); + + if let Ok(value) = serde_json::to_value(&result) { + self.endpoint_cache.put_identity(cache_key, value).await; + } + } + + Ok(result) + } + + pub async fn poll_environments(self: Arc) { + let mut interval = tokio::time::interval(Duration::from_secs( + self.settings.api_poll_frequency_seconds, + )); + + loop { + interval.tick().await; + debug!("Polling environments..."); + self.refresh_environment_caches().await; + } + } +} diff --git a/src/services/feature_utils.rs b/src/services/feature_utils.rs new file mode 100644 index 0000000..fa81f1a --- /dev/null +++ b/src/services/feature_utils.rs @@ -0,0 +1,51 @@ +use flagsmith_flag_engine::engine_eval::FlagResult; + +pub fn filter_out_server_key_only_flag_results( + flag_results: Vec, + server_key_only_feature_ids: &[u32], +) -> Vec { + flag_results + .into_iter() + .filter(|fr| !server_key_only_feature_ids.contains(&fr.metadata.feature_id)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use flagsmith_flag_engine::engine_eval::FeatureMetadata; + use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; + + fn create_flag_result(id: u32, name: &str) -> FlagResult { + FlagResult { + enabled: true, + name: name.to_string(), + value: FlagsmithValue { + value_type: FlagsmithValueType::String, + value: format!("value{}", id), + }, + reason: "DEFAULT".to_string(), + metadata: FeatureMetadata { feature_id: id }, + } + } + + #[test] + fn test_filter_out_server_key_only_features() { + // Given + let flag_results = vec![ + create_flag_result(1, "feature_1"), + create_flag_result(2, "feature_2"), + create_flag_result(3, "feature_3"), + ]; + + let server_key_only_ids = vec![3]; + + // When + let result = filter_out_server_key_only_flag_results(flag_results, &server_key_only_ids); + + // Then + assert_eq!(result.len(), 2); + assert_eq!(result[0].metadata.feature_id, 1); + assert_eq!(result[1].metadata.feature_id, 2); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..efaf4e3 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,4 @@ +pub mod environment; +pub mod feature_utils; + +pub use environment::EnvironmentService; diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..43ae606 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,4 @@ +use crate::services::EnvironmentService; +use std::sync::Arc; + +pub type AppState = Arc; diff --git a/tests/fixtures.rs b/tests/fixtures.rs new file mode 100644 index 0000000..a4f12ba --- /dev/null +++ b/tests/fixtures.rs @@ -0,0 +1,117 @@ +use serde_json::json; + +pub fn environment_1_api_key() -> String { + "environment_1_api_key".to_string() +} + +pub fn environment_1() -> serde_json::Value { + json!({ + "updated_at": "1969-07-20T20:17:40Z", + "feature_states": [ + { + "multivariate_feature_state_values": [], + "feature_state_value": "feature_1_value", + "feature": { + "name": "feature_1", + "type": "STANDARD", + "id": 1, + }, + "enabled": false, + "featurestate_uuid": "uuid-1", + }, + { + "multivariate_feature_state_values": [], + "feature_state_value": "2.3", + "feature": { + "name": "feature_2", + "type": "STANDARD", + "id": 2, + }, + "enabled": true, + "featurestate_uuid": "uuid-2", + }, + { + "multivariate_feature_state_values": [], + "feature_state_value": null, + "feature": { + "name": "feature_3", + "type": "STANDARD", + "id": 3, + }, + "enabled": false, + "featurestate_uuid": "uuid-3", + } + ], + "identity_overrides": [ + { + "identifier": "overridden-id", + "identity_uuid": "0f21cde8-63c5-4e50-baca-87897fa6cd01", + "created_date": "2019-08-27T14:53:45.698555Z", + "environment_api_key": environment_1_api_key(), + "identity_features": [ + { + "django_id": 1, + "feature": {"id": 1, "name": "feature_1", "type": "STANDARD"}, + "featurestate_uuid": "1bddb9a5-7e59-42c6-9be9-625fa369749f", + "feature_state_value": "identity_override", + "enabled": true, + } + ], + "identity_traits": [] + } + ], + "api_key": environment_1_api_key(), + "project": { + "name": "project-1", + "organisation": { + "feature_analytics": false, + "name": "org-1", + "id": 1, + "persist_trait_data": true, + "stop_serving_flags": false, + }, + "id": 1, + "hide_disabled_flags": false, + "segments": [ + { + "name": "segment_1", + "rules": [ + { + "conditions": [], + "type": "ALL", + "rules": [ + { + "conditions": [ + { + "value": "test", + "operator": "EQUAL", + "property": "first_name", + } + ], + "type": "ANY", + "rules": [], + } + ], + } + ], + "id": 1, + "feature_states": [ + { + "multivariate_feature_state_values": [], + "feature_state_value": "segment_override", + "feature": { + "name": "feature_2", + "type": "STANDARD", + "id": 2, + }, + "enabled": true, + "featurestate_uuid": "uuid-segment-2", + } + ], + } + ], + "server_key_only_feature_ids": [3], + }, + "id": 1, + }) +} diff --git a/tests/test_health.rs b/tests/test_health.rs new file mode 100644 index 0000000..8016c49 --- /dev/null +++ b/tests/test_health.rs @@ -0,0 +1,109 @@ +use axum_test::TestServer; +use chrono::Utc; +use edge_proxy::config::settings::{AppSettings, HealthCheckSettings}; +use edge_proxy::routes::create_router; + +#[tokio::test] +async fn test_liveness_check() { + // Given + let settings = AppSettings::new(); + let (app, _) = create_router(settings); + let server = TestServer::new(app).unwrap(); + + // When + let response = server.get("/proxy/health/liveness").await; + + // Then + response.assert_status_ok(); +} + +#[tokio::test] +async fn test_health_check_returns_200_if_cache_was_updated_recently() { + // Given + let settings = AppSettings::new(); + let (app, service) = create_router(settings); + { + let mut last_updated = service.last_updated_at.write().await; + *last_updated = Some(Utc::now()); + } + let server = TestServer::new(app).unwrap(); + + // When/Then + for endpoint in ["/health", "/proxy/health", "/proxy/health/readiness"] { + let response = server.get(endpoint).await; + response.assert_status_ok(); + + let body: serde_json::Value = response.json(); + assert_eq!(body["status"], "ok"); + } +} + +#[tokio::test] +async fn test_health_check_returns_503_if_cache_was_not_updated() { + // Given + let settings = AppSettings::new(); + let (app, _service) = create_router(settings); + let server = TestServer::new(app).unwrap(); + // last_updated_at is None by default + + // When/Then + for endpoint in ["/health", "/proxy/health", "/proxy/health/readiness"] { + let response = server.get(endpoint).await; + response.assert_status(axum::http::StatusCode::SERVICE_UNAVAILABLE); + + let body: serde_json::Value = response.json(); + assert_eq!(body["status"], "error"); + assert_eq!(body["reason"], "environment document(s) not updated."); + assert_eq!(body["last_successful_update"], serde_json::Value::Null); + } +} + +#[tokio::test] +async fn test_health_check_returns_503_if_cache_is_stale() { + // Given + let settings = AppSettings::new(); + let (app, service) = create_router(settings); + { + let mut last_updated = service.last_updated_at.write().await; + *last_updated = Some(Utc::now() - chrono::Duration::days(10)); + } + let server = TestServer::new(app).unwrap(); + + // When/Then + for endpoint in ["/health", "/proxy/health", "/proxy/health/readiness"] { + let response = server.get(endpoint).await; + response.assert_status(axum::http::StatusCode::SERVICE_UNAVAILABLE); + + let body: serde_json::Value = response.json(); + assert_eq!(body["status"], "error"); + assert_eq!(body["reason"], "environment document(s) stale."); + assert!(body["last_successful_update"].is_string()); + } +} + +#[tokio::test] +async fn test_health_check_returns_200_if_cache_is_never_stale() { + // Given + let mut settings = AppSettings::new(); + settings.health_check = HealthCheckSettings { + environment_update_grace_period_seconds: None, + }; + let (app, service) = create_router(settings); + let last_update_time = Utc::now() - chrono::Duration::days(10); + { + let mut last_updated = service.last_updated_at.write().await; + *last_updated = Some(last_update_time); + } + let server = TestServer::new(app).unwrap(); + + // When/Then + for endpoint in ["/health", "/proxy/health", "/proxy/health/readiness"] { + let response = server.get(endpoint).await; + response.assert_status_ok(); + + let body: serde_json::Value = response.json(); + assert_eq!(body["status"], "ok"); + assert_eq!(body["reason"], serde_json::Value::Null); + assert!(body["last_successful_update"].is_string()); + } +} diff --git a/tests/test_lru_cache.rs b/tests/test_lru_cache.rs new file mode 100644 index 0000000..d5f533a --- /dev/null +++ b/tests/test_lru_cache.rs @@ -0,0 +1,248 @@ +use edge_proxy::cache::endpoint::{CacheKey, EndpointCache}; +use edge_proxy::cache::{EnvironmentsCache, LocalMemEnvironmentsCache}; +use edge_proxy::config::settings::{ + AppSettings, EndpointCacheSettings, EndpointCachesSettings, EnvironmentKeyPair, +}; +use edge_proxy::models::IdentityWithTraits; +use edge_proxy::services::environment::EnvironmentService; +use std::sync::Arc; + +mod fixtures; +use fixtures::environment_1; + +const TEST_CLIENT_KEY: &str = "test_client_key"; +const TEST_SERVER_KEY: &str = "ser.test_server_key"; + +fn create_settings_with_cache(flags_enabled: bool, identities_enabled: bool) -> AppSettings { + use edge_proxy::config::settings::{HealthCheckSettings, LoggingSettings, ServerSettings}; + + AppSettings { + environment_key_pairs: vec![EnvironmentKeyPair { + server_side_key: TEST_SERVER_KEY.to_string(), + client_side_key: TEST_CLIENT_KEY.to_string(), + }], + api_url: "http://test.api".to_string(), + api_poll_frequency_seconds: 10, + api_poll_timeout_seconds: 5, + endpoint_caches: EndpointCachesSettings { + flags: EndpointCacheSettings { + use_cache: flags_enabled, + cache_max_size: 10, + }, + identities: EndpointCacheSettings { + use_cache: identities_enabled, + cache_max_size: 10, + }, + environment_document: EndpointCacheSettings { + use_cache: false, + cache_max_size: 10, + }, + }, + server: ServerSettings::default(), + logging: LoggingSettings::default(), + health_check: HealthCheckSettings::default(), + } +} + +#[tokio::test] +async fn test_flags_endpoint_caching() { + // Given + let settings = create_settings_with_cache(true, false); + let cache = Arc::new(LocalMemEnvironmentsCache::new()); + let service = Arc::new(EnvironmentService::with_cache(settings, cache.clone())); + cache + .put_environment(TEST_CLIENT_KEY, environment_1()) + .await; + + // When + let result1 = service + .get_flags_response_data(TEST_CLIENT_KEY, None) + .await + .unwrap(); + + // Then + assert_eq!(result1.len(), 2); + + // When - second call should hit cache + let result2 = service + .get_flags_response_data(TEST_CLIENT_KEY, None) + .await + .unwrap(); + + // Then + assert_eq!(result1, result2); + + // When - test with specific feature + let result3 = service + .get_flags_response_data(TEST_CLIENT_KEY, Some("feature_1")) + .await + .unwrap(); + + // Then + assert_eq!(result3.len(), 1); + assert_eq!(result3[0].feature.name, "feature_1"); +} + +#[tokio::test] +async fn test_identities_endpoint_caching() { + // Given + let settings = create_settings_with_cache(false, true); + let cache = Arc::new(LocalMemEnvironmentsCache::new()); + let service = Arc::new(EnvironmentService::with_cache(settings, cache.clone())); + cache + .put_environment(TEST_CLIENT_KEY, environment_1()) + .await; + + use edge_proxy::models::TraitModel; + let identity = IdentityWithTraits { + identifier: "test_user".to_string(), + traits: vec![TraitModel { + trait_key: "test_trait".to_string(), + trait_value: serde_json::json!("test_value"), + }], + }; + + // When + let result1 = service + .get_identity_response_data(&identity, TEST_CLIENT_KEY) + .await + .unwrap(); + + // Then + assert!(!result1.flags.is_empty()); + + // When - second call should hit cache + let result2 = service + .get_identity_response_data(&identity, TEST_CLIENT_KEY) + .await + .unwrap(); + + // Then + assert_eq!(result1, result2); + + // When - different identity should miss cache + let identity2 = IdentityWithTraits::new("other_user".to_string()); + let result3 = service + .get_identity_response_data(&identity2, TEST_CLIENT_KEY) + .await + .unwrap(); + + // Then + assert!(!result3.flags.is_empty()); +} + +#[tokio::test] +async fn test_cache_invalidation_on_environment_update() { + // Given + let settings = create_settings_with_cache(true, true); + let cache = Arc::new(LocalMemEnvironmentsCache::new()); + let service = Arc::new(EnvironmentService::with_cache(settings, cache.clone())); + cache + .put_environment(TEST_CLIENT_KEY, environment_1()) + .await; + + let _ = service + .get_flags_response_data(TEST_CLIENT_KEY, None) + .await + .unwrap(); + + let identity = IdentityWithTraits::new("test_user".to_string()); + let _ = service + .get_identity_response_data(&identity, TEST_CLIENT_KEY) + .await + .unwrap(); + + let cache_key_flags = CacheKey::new( + TEST_CLIENT_KEY.to_string(), + "flags".to_string(), + "".to_string(), + ); + + // Then - cache should be populated + assert!( + service + .endpoint_cache + .get_flags(&cache_key_flags) + .await + .is_some() + ); + + // When - simulate environment update + service + .endpoint_cache + .clear_environment(TEST_CLIENT_KEY) + .await; + + // Then - cache should be cleared + assert!( + service + .endpoint_cache + .get_flags(&cache_key_flags) + .await + .is_none() + ); +} + +#[tokio::test] +async fn test_lru_eviction() { + // Given + let cache = EndpointCache::new(true, 2, false, 0, false, 0); + let key1 = CacheKey::new( + "env1".to_string(), + "flags".to_string(), + "feature1".to_string(), + ); + let key2 = CacheKey::new( + "env1".to_string(), + "flags".to_string(), + "feature2".to_string(), + ); + let key3 = CacheKey::new( + "env1".to_string(), + "flags".to_string(), + "feature3".to_string(), + ); + let value1 = serde_json::json!([{"feature": {"name": "feature1"}}]); + let value2 = serde_json::json!([{"feature": {"name": "feature2"}}]); + let value3 = serde_json::json!([{"feature": {"name": "feature3"}}]); + + // When - fill cache to capacity + cache.put_flags(key1.clone(), value1.clone()).await; + cache.put_flags(key2.clone(), value2.clone()).await; + + // Then - both should be in cache + assert_eq!(cache.get_flags(&key1).await, Some(value1.clone())); + assert_eq!(cache.get_flags(&key2).await, Some(value2.clone())); + + // When - add third item + cache.put_flags(key3.clone(), value3.clone()).await; + + // Then - key1 should be evicted (LRU) + assert_eq!(cache.get_flags(&key1).await, None); + assert_eq!(cache.get_flags(&key2).await, Some(value2)); + assert_eq!(cache.get_flags(&key3).await, Some(value3)); +} + +#[tokio::test] +async fn test_disabled_cache() { + // Given + let settings = create_settings_with_cache(false, false); + let cache = Arc::new(LocalMemEnvironmentsCache::new()); + let service = Arc::new(EnvironmentService::with_cache(settings, cache.clone())); + cache + .put_environment(TEST_CLIENT_KEY, environment_1()) + .await; + + // Then - caches should be disabled + assert!(!service.endpoint_cache.is_flags_cache_enabled()); + assert!(!service.endpoint_cache.is_identities_cache_enabled()); + + // When - should still work without caching + let result = service + .get_flags_response_data(TEST_CLIENT_KEY, None) + .await + .unwrap(); + + // Then + assert!(!result.is_empty()); +} diff --git a/tests/test_server.rs b/tests/test_server.rs new file mode 100644 index 0000000..2a599af --- /dev/null +++ b/tests/test_server.rs @@ -0,0 +1,255 @@ +mod fixtures; + +use axum_test::TestServer; +use edge_proxy::config::settings::{AppSettings, EnvironmentKeyPair}; +use edge_proxy::routes::create_router; +use fixtures::*; +use serde_json::json; + +async fn setup_test_server() -> TestServer { + let settings = AppSettings { + environment_key_pairs: vec![EnvironmentKeyPair { + server_side_key: "ser.test_key".to_string(), + client_side_key: environment_1_api_key(), + }], + api_url: "http://test".to_string(), + ..AppSettings::new() + }; + + let (app, service) = create_router(settings); + + service + .cache + .put_environment(&environment_1_api_key(), environment_1()) + .await; + + TestServer::new(app).unwrap() +} + +#[tokio::test] +async fn test_get_flags() { + // Given + let server = setup_test_server().await; + + // When + let response = server + .get("/api/v1/flags") + .add_header("X-Environment-Key", &environment_1_api_key()) + .await; + + // Then + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + let flags = body.as_array().unwrap(); + assert_eq!(flags.len(), 2); + + let feature_1 = flags + .iter() + .find(|f| f["feature"]["name"] == "feature_1") + .expect("feature_1 should be in response"); + assert_eq!(feature_1["enabled"], false); + assert_eq!(feature_1["feature"]["id"], 1); + assert_eq!(feature_1["feature_state_value"], "feature_1_value"); + + let feature_2 = flags + .iter() + .find(|f| f["feature"]["name"] == "feature_2") + .expect("feature_2 should be in response"); + assert_eq!(feature_2["enabled"], true); + assert_eq!(feature_2["feature"]["id"], 2); + assert_eq!(feature_2["feature_state_value"], "2.3"); +} + +#[tokio::test] +async fn test_get_flags_single_feature() { + // Given + let server = setup_test_server().await; + + // When + let response = server + .get("/api/v1/flags?feature=feature_1") + .add_header("X-Environment-Key", &environment_1_api_key()) + .await; + + // Then + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + assert_eq!(body["feature"]["name"], "feature_1"); + assert_eq!(body["enabled"], false); + assert_eq!(body["feature"]["id"], 1); + assert_eq!(body["feature_state_value"], "feature_1_value"); +} + +#[tokio::test] +async fn test_get_flags_single_feature_server_key_only_returns_404() { + // Given + let server = setup_test_server().await; + + // When + let response = server + .get("/api/v1/flags?feature=feature_3") + .add_header("X-Environment-Key", &environment_1_api_key()) + .await; + + // Then + response.assert_status(axum::http::StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!(body["status"], "not_found"); + assert_eq!(body["message"], "feature 'feature_3' not found"); +} + +#[tokio::test] +async fn test_get_flags_unknown_key() { + // Given + let server = setup_test_server().await; + + // When + let response = server + .get("/api/v1/flags") + .add_header("X-Environment-Key", "unknown_key") + .await; + + // Then + response.assert_status(axum::http::StatusCode::UNAUTHORIZED); + let body: serde_json::Value = response.json(); + assert_eq!(body["status"], "unauthorized"); + assert!(body["message"].as_str().unwrap().contains("unknown key")); +} + +#[tokio::test] +async fn test_post_identity_with_traits() { + // Given + let server = setup_test_server().await; + let identity_data = json!({ + "identifier": "test_identity", + "traits": [ + {"trait_key": "email", "trait_value": "test@example.com"} + ] + }); + + // When + let response = server + .post("/api/v1/identities/") + .add_header("X-Environment-Key", &environment_1_api_key()) + .json(&identity_data) + .await; + + // Then + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + assert!(body["flags"].is_array()); + let flags = body["flags"].as_array().unwrap(); + assert_eq!(flags.len(), 2); + assert_eq!(body["traits"][0]["trait_key"], "email"); + assert_eq!(body["traits"][0]["trait_value"], "test@example.com"); + + let feature_names: Vec<&str> = flags + .iter() + .map(|f| f["feature"]["name"].as_str().unwrap()) + .collect(); + assert!(feature_names.contains(&"feature_1")); + assert!(feature_names.contains(&"feature_2")); +} + +#[tokio::test] +async fn test_post_identity_with_override() { + // Given + let server = setup_test_server().await; + let identity_data = json!({ + "identifier": "overridden-id", + "traits": [] + }); + + // When + let response = server + .post("/api/v1/identities/") + .add_header("X-Environment-Key", &environment_1_api_key()) + .json(&identity_data) + .await; + + // Then + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + let flags = body["flags"].as_array().unwrap(); + let feature_1 = flags + .iter() + .find(|f| f["feature"]["name"] == "feature_1") + .expect("feature_1 should be in response"); + assert_eq!(feature_1["feature_state_value"], "identity_override"); + assert_eq!(feature_1["enabled"], true); +} + +#[tokio::test] +async fn test_get_identities() { + // Given + let server = setup_test_server().await; + + // When + let response = server + .get("/api/v1/identities/?identifier=test_identity") + .add_header("X-Environment-Key", &environment_1_api_key()) + .await; + + // Then + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + assert!(body["flags"].is_array()); + assert!(body["traits"].is_array()); + assert_eq!(body["traits"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn test_get_environment_document() { + // Given + let settings = AppSettings { + environment_key_pairs: vec![EnvironmentKeyPair { + server_side_key: "ser.test_key".to_string(), + client_side_key: "client_key".to_string(), + }], + ..AppSettings::new() + }; + let (app, service) = create_router(settings); + service + .cache + .put_environment("client_key", environment_1()) + .await; + let server = TestServer::new(app).unwrap(); + + // When + let response = server + .get("/api/v1/environment-document") + .add_header("X-Environment-Key", "ser.test_key") + .await; + + // Then + response.assert_status_ok(); + let body: serde_json::Value = response.json(); + assert_eq!(body["api_key"], environment_1_api_key()); +} + +#[tokio::test] +async fn test_get_environment_document_missing_key() { + // Given + let server = setup_test_server().await; + + // When + let response = server.get("/api/v1/environment-document").await; + + // Then + response.assert_status(axum::http::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_get_environment_document_client_key_rejected() { + // Given + let server = setup_test_server().await; + + // When + let response = server + .get("/api/v1/environment-document") + .add_header("X-Environment-Key", &environment_1_api_key()) + .await; + + // Then + response.assert_status(axum::http::StatusCode::UNAUTHORIZED); +}