diff --git a/.changeset/fluffy-tigers-film.md b/.changeset/fluffy-tigers-film.md new file mode 100644 index 000000000..fca95711e --- /dev/null +++ b/.changeset/fluffy-tigers-film.md @@ -0,0 +1,5 @@ +--- +'hive-apollo-router-plugin': patch +--- + +Updated `hive-apollo-router-plugin` to use `hive-console-sdk` from crates.io instead of a local dependency. The plugin now uses `graphql-tools::parser` instead of `graphql-parser` to leverage the parser we now ship in `graphql-tools` crate. diff --git a/Cargo.toml b/Cargo.toml index bf9128159..5e252f84f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "2" -members = ["packages/libraries/router", "scripts/compress", "packages/libraries/sdk-rs"] +members = ["packages/libraries/router", "scripts/compress"] diff --git a/configs/cargo/Cargo.lock b/configs/cargo/Cargo.lock index aa72fdc58..2dcc37acf 100644 --- a/configs/cargo/Cargo.lock +++ b/configs/cargo/Cargo.lock @@ -1240,9 +1240,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.43" +version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", "jobserver", @@ -1325,15 +1325,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" -[[package]] -name = "colored" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" -dependencies = [ - "windows-sys 0.52.0", -] - [[package]] name = "combine" version = "4.6.7" @@ -2039,9 +2030,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" [[package]] name = "fixedbitset" @@ -2414,15 +2405,18 @@ dependencies = [ [[package]] name = "graphql-tools" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68fb22726aceab7a8933cdcff4201e1cdbcc7c7394df5bc1ebdcf27b44376433" +checksum = "9513662272317e955f5d72b13b4015ba31c84d68225f4b5d0a2ad6ffceec1258" dependencies = [ - "graphql-parser", + "combine", + "itoa", "lazy_static", + "ryu", "serde", "serde_json", "serde_with", + "thiserror 2.0.17", ] [[package]] @@ -2600,7 +2594,7 @@ dependencies = [ [[package]] name = "hive-apollo-router-plugin" -version = "2.3.6" +version = "3.0.0" dependencies = [ "anyhow", "apollo-router", @@ -2608,7 +2602,7 @@ dependencies = [ "axum-core 0.5.5", "bytes", "futures", - "graphql-parser", + "graphql-tools", "hive-console-sdk", "http 1.3.1", "http-body-util", @@ -2628,18 +2622,17 @@ dependencies = [ [[package]] name = "hive-console-sdk" -version = "0.2.3" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450613f002f14d421378aa8dde956b55c91db4a7b66d41b8c18bc8d1d51e671" dependencies = [ "anyhow", "async-dropper-simple", "async-trait", - "axum-core 0.5.5", "futures-util", - "graphql-parser", "graphql-tools", "lazy_static", "md5", - "mockito", "moka", "once_cell", "recloser", @@ -2933,7 +2926,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3174,9 +3167,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jobserver" @@ -3493,9 +3486,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md5" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "mediatype" @@ -3588,31 +3581,6 @@ dependencies = [ "syn 2.0.108", ] -[[package]] -name = "mockito" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e0603425789b4a70fcc4ac4f5a46a566c116ee3e2a6b768dc623f7719c611de" -dependencies = [ - "assert-json-diff", - "bytes", - "colored", - "futures-core", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "hyper 1.7.0", - "hyper-util", - "log", - "pin-project-lite", - "rand 0.9.2", - "regex", - "serde_json", - "serde_urlencoded", - "similar", - "tokio", -] - [[package]] name = "moka" version = "0.12.11" @@ -4783,6 +4751,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "h2", "http 1.3.1", "http-body 1.0.1", "http-body-util", @@ -5082,9 +5051,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -6041,9 +6010,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", "base64 0.22.1", @@ -6707,7 +6676,7 @@ checksum = "810ce18ed2112484b0d4e15d022e5f598113e220c53e373fb31e67e21670c1ce" dependencies = [ "windows-implement 0.59.0", "windows-interface", - "windows-result 0.3.4", + "windows-result", "windows-strings 0.3.1", "windows-targets 0.53.5", ] @@ -6721,23 +6690,10 @@ dependencies = [ "windows-implement 0.60.2", "windows-interface", "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-result", "windows-strings 0.4.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-future" version = "0.2.1" @@ -6813,15 +6769,6 @@ 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.3.1" @@ -6840,15 +6787,6 @@ 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" diff --git a/docker/docker.hcl b/docker/docker.hcl index e85cc9350..c922dcbdf 100644 --- a/docker/docker.hcl +++ b/docker/docker.hcl @@ -341,8 +341,6 @@ target "apollo-router" { inherits = ["router-base", get_target()] contexts = { router_pkg = "${PWD}/packages/libraries/router" - sdk_rs_pkg = "${PWD}/packages/libraries/sdk-rs" - usage_service = "${PWD}/packages/services/usage" config = "${PWD}/configs/cargo" } args = { diff --git a/docker/router.dockerfile b/docker/router.dockerfile index c9de07d3a..b247e8f6f 100644 --- a/docker/router.dockerfile +++ b/docker/router.dockerfile @@ -1,6 +1,5 @@ # syntax=docker/dockerfile:1 FROM scratch AS router_pkg -FROM scratch AS sdk_rs_pkg FROM scratch AS config FROM rust:1.91.1-slim-bookworm AS build @@ -15,29 +14,11 @@ RUN rustup component add rustfmt WORKDIR /usr/src # Create blank projects RUN USER=root cargo new router -RUN USER=root cargo new sdk-rs # Copy Cargo files COPY --from=router_pkg Cargo.toml /usr/src/router/ -COPY --from=sdk_rs_pkg Cargo.toml /usr/src/sdk-rs/ COPY --from=config Cargo.lock /usr/src/router/ -# Copy usage report schema -# `agent.rs` uses it -# So we need to place it accordingly -COPY --from=usage_service usage-report-v2.schema.json /usr/src/sdk-rs/ - -WORKDIR /usr/src/sdk-rs -# Get the dependencies cached, so we can use dummy input files so Cargo wont fail -RUN echo 'fn main() { println!(""); }' > ./src/main.rs -RUN echo 'fn main() { println!(""); }' > ./src/lib.rs -RUN cargo build --release - -# Copy in the actual source code -COPY --from=sdk_rs_pkg src ./src -RUN touch ./src/main.rs -RUN touch ./src/lib.rs - WORKDIR /usr/src/router # Get the dependencies cached, so we can use dummy input files so Cargo wont fail RUN echo 'fn main() { println!(""); }' > ./src/main.rs diff --git a/package.json b/package.json index 4eae21732..73d25b539 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "prettier": "prettier --cache --write --list-different --ignore-unknown \"**/*\"", "release": "pnpm build:libraries && changeset publish", "release:docs:update-version": "tsx scripts/sync-docker-image-tag-docs.ts", - "release:version": "changeset version && pnpm --filter hive-apollo-router-plugin --filter hive-console-sdk-rs sync-cargo-file && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme && pnpm run release:docs:update-version", + "release:version": "changeset version && pnpm --filter hive-apollo-router-plugin sync-cargo-file && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme && pnpm run release:docs:update-version", "seed:org": "tsx scripts/seed-organization.mts", "seed:schemas": "tsx scripts/seed-schemas.ts", "seed:usage": "tsx scripts/seed-usage.ts", diff --git a/packages/libraries/router/Cargo.toml b/packages/libraries/router/Cargo.toml index cde2273ee..38f9fa9d2 100644 --- a/packages/libraries/router/Cargo.toml +++ b/packages/libraries/router/Cargo.toml @@ -19,7 +19,7 @@ path = "src/lib.rs" [dependencies] apollo-router = { version = "^2.0.0" } axum-core = "0.5" -hive-console-sdk = { path = "../sdk-rs", version = "0" } +hive-console-sdk = "0.3.3" sha2 = { version = "0.10.8", features = ["std"] } anyhow = "1" tracing = "0.1" @@ -33,7 +33,7 @@ tokio = { version = "1.36.0", features = ["full"] } tower = { version = "0.5", features = ["full"] } http = "1" http-body-util = "0.1" -graphql-parser = "0.4.1" +graphql-tools = "0.4.2" rand = "0.9.0" tokio-util = "0.7.16" diff --git a/packages/libraries/router/package.json b/packages/libraries/router/package.json index 88e3d299b..d762644be 100644 --- a/packages/libraries/router/package.json +++ b/packages/libraries/router/package.json @@ -4,8 +4,5 @@ "private": true, "scripts": { "sync-cargo-file": "./sync-cargo-file.sh" - }, - "dependencies": { - "hive-console-sdk-rs": "workspace:*" } } diff --git a/packages/libraries/router/src/registry.rs b/packages/libraries/router/src/registry.rs index fb48b76bc..f72543ac7 100644 --- a/packages/libraries/router/src/registry.rs +++ b/packages/libraries/router/src/registry.rs @@ -1,7 +1,7 @@ use crate::consts::PLUGIN_VERSION; use crate::registry_logger::Logger; use anyhow::{anyhow, Result}; -use hive_console_sdk::supergraph_fetcher::sync::SupergraphFetcherSyncState; +use hive_console_sdk::supergraph_fetcher::sync_fetcher::SupergraphFetcherSyncState; use hive_console_sdk::supergraph_fetcher::SupergraphFetcher; use sha2::Digest; use sha2::Sha256; diff --git a/packages/libraries/router/src/usage.rs b/packages/libraries/router/src/usage.rs index d8eadcd1d..8cba5919d 100644 --- a/packages/libraries/router/src/usage.rs +++ b/packages/libraries/router/src/usage.rs @@ -6,8 +6,8 @@ use apollo_router::services::*; use apollo_router::Context; use core::ops::Drop; use futures::StreamExt; -use graphql_parser::parse_schema; -use graphql_parser::schema::Document; +use graphql_tools::parser::parse_schema; +use graphql_tools::parser::schema::Document; use hive_console_sdk::agent::usage_agent::UsageAgentExt; use hive_console_sdk::agent::usage_agent::{ExecutionReport, UsageAgent}; use http::HeaderValue; diff --git a/packages/libraries/sdk-rs/.dockerignore b/packages/libraries/sdk-rs/.dockerignore deleted file mode 100644 index 90f043127..000000000 --- a/packages/libraries/sdk-rs/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -target/** diff --git a/packages/libraries/sdk-rs/CHANGELOG.md b/packages/libraries/sdk-rs/CHANGELOG.md deleted file mode 100644 index ded95cd26..000000000 --- a/packages/libraries/sdk-rs/CHANGELOG.md +++ /dev/null @@ -1,228 +0,0 @@ -# hive-console-sdk-rs - -## 0.3.0 - -### Minor Changes - -- [#7379](https://github.com/graphql-hive/console/pull/7379) - [`b134461`](https://github.com/graphql-hive/console/commit/b13446109d9663ccabef07995eb25cf9dff34f37) - Thanks [@ardatan](https://github.com/ardatan)! - Breaking Changes to avoid future breaking - changes; - - Switch to [Builder](https://rust-unofficial.github.io/patterns/patterns/creational/builder.html) - pattern for `SupergraphFetcher`, `PersistedDocumentsManager` and `UsageAgent` structs. - - No more `try_new` or `try_new_async` or `try_new_sync` functions, instead use - `SupergraphFetcherBuilder`, `PersistedDocumentsManagerBuilder` and `UsageAgentBuilder` structs to - create instances. - - Benefits; - - - No need to provide all parameters at once when creating an instance even for default values. - - Example; - - ```rust - // Before - let fetcher = SupergraphFetcher::try_new_async( - "SOME_ENDPOINT", // endpoint - "SOME_KEY", - "MyUserAgent/1.0".to_string(), - Duration::from_secs(5), // connect_timeout - Duration::from_secs(10), // request_timeout - false, // accept_invalid_certs - 3, // retry_count - )?; - - // After - // No need to provide all parameters at once, can use default values - let fetcher = SupergraphFetcherBuilder::new() - .endpoint("SOME_ENDPOINT".to_string()) - .key("SOME_KEY".to_string()) - .build_async()?; - ``` - - - Easier to add new configuration options in the future without breaking existing code. - - Example; - - ```rust - let fetcher = SupergraphFetcher::try_new_async( - "SOME_ENDPOINT", // endpoint - "SOME_KEY", - "MyUserAgent/1.0".to_string(), - Duration::from_secs(5), // connect_timeout - Duration::from_secs(10), // request_timeout - false, // accept_invalid_certs - 3, // retry_count - circuit_breaker_config, // Breaking Change -> new parameter added - )?; - - let fetcher = SupergraphFetcherBuilder::new() - .endpoint("SOME_ENDPOINT".to_string()) - .key("SOME_KEY".to_string()) - .build_async()?; // No breaking change, circuit_breaker_config can be added later if needed - ``` - -### Patch Changes - -- [#7379](https://github.com/graphql-hive/console/pull/7379) - [`b134461`](https://github.com/graphql-hive/console/commit/b13446109d9663ccabef07995eb25cf9dff34f37) - Thanks [@ardatan](https://github.com/ardatan)! - Circuit Breaker Implementation and Multiple - Endpoints Support - - Implementation of Circuit Breakers in Hive Console Rust SDK, you can learn more - [here](https://the-guild.dev/graphql/hive/product-updates/2025-12-04-cdn-mirror-and-circuit-breaker) - - Breaking Changes: - - Now `endpoint` configuration accepts multiple endpoints as an array for `SupergraphFetcherBuilder` - and `PersistedDocumentsManager`. - - ```diff - SupergraphFetcherBuilder::default() - - .endpoint(endpoint) - + .add_endpoint(endpoint1) - + .add_endpoint(endpoint2) - ``` - - This change requires updating the configuration structure to accommodate multiple endpoints. - -## 0.2.3 - -### Patch Changes - -- [#7446](https://github.com/graphql-hive/console/pull/7446) - [`0ac2e06`](https://github.com/graphql-hive/console/commit/0ac2e06fd6eb94c9d9817f78faf6337118f945eb) - Thanks [@ardatan](https://github.com/ardatan)! - Fixed the stack overflow error while collecting - schema coordinates from the recursive input object types correctly; - - Let's consider the following schema: - - ```graphql - input RecursiveInput { - field: String - nested: RecursiveInput - } - ``` - - And you have an operation that uses this input type: - - ```graphql - query UserQuery($input: RecursiveInput!) { - user(input: $input) { - id - } - } - ``` - - When collecting schema coordinates from operations that use this input type, the previous - implementation could enter an infinite recursion when traversing the nested `RecursiveInput` type. - This would lead to a stack overflow error. - -- [#7448](https://github.com/graphql-hive/console/pull/7448) - [`4b796f9`](https://github.com/graphql-hive/console/commit/4b796f95bbc0fc37aac2c3a108a6165858b42b49) - Thanks [@kamilkisiela](https://github.com/kamilkisiela)! - export `minify_query` and - `normalize_operation` functions (mainly for Hive Router) - -- [#7439](https://github.com/graphql-hive/console/pull/7439) - [`a9905ec`](https://github.com/graphql-hive/console/commit/a9905ec7198cf1bec977a281c5021e0ef93c2c34) - Thanks [@jdolle](https://github.com/jdolle)! - Remove the usage flag (!) from non-null, but unused - variables to match js sdk - -## 0.2.2 - -### Patch Changes - -- [#7405](https://github.com/graphql-hive/console/pull/7405) - [`24c0998`](https://github.com/graphql-hive/console/commit/24c099818e4dfec43feea7775e8189d0f305a10c) - Thanks [@ardatan](https://github.com/ardatan)! - Use the JSON Schema specification of the usage - reports directly to generate Rust structs as a source of truth instead of manually written types - -## 0.2.1 - -### Patch Changes - -- [#7364](https://github.com/graphql-hive/console/pull/7364) - [`69e2f74`](https://github.com/graphql-hive/console/commit/69e2f74ab867ee5e97bbcfcf6a1b69bb23ccc7b2) - Thanks [@ardatan](https://github.com/ardatan)! - Fix the bug where reports were not being sent - correctly due to missing headers - -## 0.2.0 - -### Minor Changes - -- [#7246](https://github.com/graphql-hive/console/pull/7246) - [`cc6cd28`](https://github.com/graphql-hive/console/commit/cc6cd28eb52d774683c088ce456812d3541d977d) - Thanks [@ardatan](https://github.com/ardatan)! - Breaking; - - - `SupergraphFetcher` now has two different modes: async and sync. You can choose between - `SupergraphFetcherAsyncClient` and `SupergraphFetcherSyncClient` based on your needs. See the - examples at the bottom. - - `SupergraphFetcher` now has a new `retry_count` parameter to specify how many times to retry - fetching the supergraph in case of failures. - - `PersistedDocumentsManager` new needs `user_agent` parameter to be sent to Hive Console when - fetching persisted queries. - - `UsageAgent::new` is now `UsageAgent::try_new` and it returns a `Result` with `Arc`, so you can - freely clone it across threads. This change was made to handle potential errors during the - creation of the HTTP client. Make sure to handle the `Result` when creating a `UsageAgent`. - - ```rust - // Sync Mode - let fetcher = SupergraphFetcher::try_new_sync(/* params */) - .map_err(|e| anyhow!("Failed to create SupergraphFetcher: {}", e))?; - - // Use the fetcher to fetch the supergraph (Sync) - let supergraph = fetcher - .fetch_supergraph() - .map_err(|e| anyhow!("Failed to fetch supergraph: {}", e))?; - - // Async Mode - - let fetcher = SupergraphFetcher::try_new_async(/* params */) - .map_err(|e| anyhow!("Failed to create SupergraphFetcher: {}", e))?; - - // Use the fetcher to fetch the supergraph (Async) - let supergraph = fetcher - .fetch_supergraph() - .await - .map_err(|e| anyhow!("Failed to fetch supergraph: {}", e))?; - ``` - -## 0.1.1 - -### Patch Changes - -- [#7248](https://github.com/graphql-hive/console/pull/7248) - [`d8f6e25`](https://github.com/graphql-hive/console/commit/d8f6e252ee3cd22948eb0d64b9d25c9b04dba47c) - Thanks [@n1ru4l](https://github.com/n1ru4l)! - Support project and personal access tokens (`hvp1/` - and `hvu1/`). - -## 0.1.0 - -### Minor Changes - -- [#7196](https://github.com/graphql-hive/console/pull/7196) - [`7878736`](https://github.com/graphql-hive/console/commit/7878736643578ab23d95412b893c091e32691e60) - Thanks [@ardatan](https://github.com/ardatan)! - Breaking; - - - `UsageAgent` now accepts `Duration` for `connect_timeout` and `request_timeout` instead of - `u64`. - - `SupergraphFetcher` now accepts `Duration` for `connect_timeout` and `request_timeout` instead - of `u64`. - - `PersistedDocumentsManager` now accepts `Duration` for `connect_timeout` and `request_timeout` - instead of `u64`. - - Use original `graphql-parser` and `graphql-tools` crates instead of forked versions. - -## 0.0.1 - -### Patch Changes - -- [#7143](https://github.com/graphql-hive/console/pull/7143) - [`b80e896`](https://github.com/graphql-hive/console/commit/b80e8960f492e3bcfe1012caab294d9066d86fe3) - Thanks [@ardatan](https://github.com/ardatan)! - Extract Hive Console integration implementation - into a new package `hive-console-sdk` which can be used by any Rust library for Hive Console - integration - - It also includes a refactor to use less Mutexes like replacing `lru` + `Mutex` with the - thread-safe `moka` package. Only one place that handles queueing uses `Mutex` now. diff --git a/packages/libraries/sdk-rs/Cargo.toml b/packages/libraries/sdk-rs/Cargo.toml deleted file mode 100644 index b828b5fa5..000000000 --- a/packages/libraries/sdk-rs/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "hive-console-sdk" -repository = "https://github.com/graphql-hive/console/" -edition = "2021" -license = "MIT" -publish = true -version = "0.3.0" -description = "Rust SDK for Hive Console" - -[lib] -name = "hive_console_sdk" -path = "src/lib.rs" - -[dependencies] -async-trait = "0.1.77" -axum-core = "0.5" -thiserror = "2.0.11" -reqwest = { version = "0.12.24", default-features = false, features = [ - "rustls-tls", - "blocking", -] } -reqwest-retry = "0.8.0" -reqwest-middleware = { version = "0.4.2", features = ["json"]} -anyhow = "1" -tracing = "0.1" -serde = "1" -tokio = { version = "1.36.0", features = ["full"] } -graphql-tools = "0.4.0" -graphql-parser = "0.4.1" -md5 = "0.7.0" -serde_json = "1" -moka = { version = "0.12.10", features = ["future", "sync"] } -sha2 = { version = "0.10.8", features = ["std"] } -tokio-util = "0.7.16" -regex-automata = "0.4.10" -once_cell = "1.21.3" -retry-policies = "0.5.0" -recloser = "1.3.1" -futures-util = "0.3.31" -typify = "0.5.0" -regress = "0.10.5" -lazy_static = "1.5.0" -async-dropper-simple = { version = "0.2.6", features = ["tokio", "no-default-bound"] } - -[dev-dependencies] -mockito = "1.7.0" diff --git a/packages/libraries/sdk-rs/package.json b/packages/libraries/sdk-rs/package.json deleted file mode 100644 index 18b3ab55f..000000000 --- a/packages/libraries/sdk-rs/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "hive-console-sdk-rs", - "version": "0.3.0", - "private": true, - "scripts": { - "sync-cargo-file": "./sync-cargo-file.sh" - } -} diff --git a/packages/libraries/sdk-rs/src/agent/buffer.rs b/packages/libraries/sdk-rs/src/agent/buffer.rs deleted file mode 100644 index 5c7add3f4..000000000 --- a/packages/libraries/sdk-rs/src/agent/buffer.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::collections::VecDeque; - -use tokio::sync::Mutex; - -pub struct Buffer { - max_size: usize, - queue: Mutex>, -} - -pub enum AddStatus { - Full { drained: Vec }, - Ok, -} - -impl Buffer { - pub fn new(max_size: usize) -> Self { - Self { - queue: Mutex::new(VecDeque::with_capacity(max_size)), - max_size, - } - } - - pub async fn add(&self, item: T) -> AddStatus { - let mut queue = self.queue.lock().await; - if queue.len() >= self.max_size { - let mut drained: Vec = queue.drain(..).collect(); - drained.push(item); - AddStatus::Full { drained } - } else { - queue.push_back(item); - AddStatus::Ok - } - } - - pub async fn drain(&self) -> Vec { - let mut queue = self.queue.lock().await; - queue.drain(..).collect() - } -} diff --git a/packages/libraries/sdk-rs/src/agent/builder.rs b/packages/libraries/sdk-rs/src/agent/builder.rs deleted file mode 100644 index a7831a2ac..000000000 --- a/packages/libraries/sdk-rs/src/agent/builder.rs +++ /dev/null @@ -1,229 +0,0 @@ -use std::{sync::Arc, time::Duration}; - -use async_dropper_simple::AsyncDropper; -use once_cell::sync::Lazy; -use recloser::AsyncRecloser; -use reqwest::header::{HeaderMap, HeaderValue}; -use reqwest_middleware::ClientBuilder; -use reqwest_retry::RetryTransientMiddleware; - -use crate::agent::buffer::Buffer; -use crate::agent::usage_agent::{non_empty_string, AgentError, UsageAgent, UsageAgentInner}; -use crate::agent::utils::OperationProcessor; -use crate::circuit_breaker; -use retry_policies::policies::ExponentialBackoff; - -pub struct UsageAgentBuilder { - token: Option, - endpoint: String, - target_id: Option, - buffer_size: usize, - connect_timeout: Duration, - request_timeout: Duration, - accept_invalid_certs: bool, - flush_interval: Duration, - retry_policy: ExponentialBackoff, - user_agent: Option, - circuit_breaker: Option, -} - -pub static DEFAULT_HIVE_USAGE_ENDPOINT: &str = "https://app.graphql-hive.com/usage"; - -impl Default for UsageAgentBuilder { - fn default() -> Self { - Self { - endpoint: DEFAULT_HIVE_USAGE_ENDPOINT.to_string(), - token: None, - target_id: None, - buffer_size: 1000, - connect_timeout: Duration::from_secs(5), - request_timeout: Duration::from_secs(15), - accept_invalid_certs: false, - flush_interval: Duration::from_secs(5), - retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), - user_agent: None, - circuit_breaker: None, - } - } -} - -fn is_legacy_token(token: &str) -> bool { - !token.starts_with("hvo1/") && !token.starts_with("hvu1/") && !token.starts_with("hvp1/") -} - -impl UsageAgentBuilder { - /// Your [Registry Access Token](https://the-guild.dev/graphql/hive/docs/management/targets#registry-access-tokens) with write permission. - pub fn token(mut self, token: String) -> Self { - if let Some(token) = non_empty_string(Some(token)) { - self.token = Some(token); - } - self - } - /// For self-hosting, you can override `/usage` endpoint (defaults to `https://app.graphql-hive.com/usage`). - pub fn endpoint(mut self, endpoint: String) -> Self { - if let Some(endpoint) = non_empty_string(Some(endpoint)) { - self.endpoint = endpoint; - } - self - } - /// A target ID, this can either be a slug following the format “$organizationSlug/$projectSlug/$targetSlug” (e.g “the-guild/graphql-hive/staging”) or an UUID (e.g. “a0f4c605-6541-4350-8cfe-b31f21a4bf80”). To be used when the token is configured with an organization access token. - pub fn target_id(mut self, target_id: String) -> Self { - if let Some(target_id) = non_empty_string(Some(target_id)) { - self.target_id = Some(target_id); - } - self - } - /// A maximum number of operations to hold in a buffer before sending to Hive Console - /// Default: 1000 - pub fn buffer_size(mut self, buffer_size: usize) -> Self { - self.buffer_size = buffer_size; - self - } - /// A timeout for only the connect phase of a request to Hive Console - /// Default: 5 seconds - pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self { - self.connect_timeout = connect_timeout; - self - } - /// A timeout for the entire request to Hive Console - /// Default: 15 seconds - pub fn request_timeout(mut self, request_timeout: Duration) -> Self { - self.request_timeout = request_timeout; - self - } - /// Accepts invalid SSL certificates - /// Default: false - pub fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self { - self.accept_invalid_certs = accept_invalid_certs; - self - } - /// Frequency of flushing the buffer to the server - /// Default: 5 seconds - pub fn flush_interval(mut self, flush_interval: Duration) -> Self { - self.flush_interval = flush_interval; - self - } - /// User-Agent header to be sent with each request - pub fn user_agent(mut self, user_agent: String) -> Self { - if let Some(user_agent) = non_empty_string(Some(user_agent)) { - self.user_agent = Some(user_agent); - } - self - } - /// Retry policy for sending reports - /// Default: ExponentialBackoff with max 3 retries - pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { - self.retry_policy = retry_policy; - self - } - /// Maximum number of retries for sending reports - /// Default: ExponentialBackoff with max 3 retries - pub fn max_retries(mut self, max_retries: u32) -> Self { - self.retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); - self - } - pub(crate) fn build_agent(self) -> Result { - let mut default_headers = HeaderMap::new(); - - default_headers.insert("X-Usage-API-Version", HeaderValue::from_static("2")); - - let token = match self.token { - Some(token) => token, - None => return Err(AgentError::MissingToken), - }; - - let mut authorization_header = HeaderValue::from_str(&format!("Bearer {}", token)) - .map_err(|_| AgentError::InvalidToken)?; - - authorization_header.set_sensitive(true); - - default_headers.insert(reqwest::header::AUTHORIZATION, authorization_header); - - default_headers.insert( - reqwest::header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), - ); - - let mut reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(self.accept_invalid_certs) - .connect_timeout(self.connect_timeout) - .timeout(self.request_timeout) - .default_headers(default_headers); - - if let Some(user_agent) = &self.user_agent { - reqwest_agent = reqwest_agent.user_agent(user_agent); - } - - let reqwest_agent = reqwest_agent - .build() - .map_err(AgentError::HTTPClientCreationError)?; - let client = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) - .build(); - - let mut endpoint = self.endpoint; - - match self.target_id { - Some(_) if is_legacy_token(&token) => return Err(AgentError::TargetIdWithLegacyToken), - Some(target_id) if !is_legacy_token(&token) => { - let target_id = validate_target_id(&target_id)?; - endpoint.push_str(&format!("/{}", target_id)); - } - None if !is_legacy_token(&token) => return Err(AgentError::MissingTargetId), - _ => {} - } - - let circuit_breaker = if let Some(cb) = self.circuit_breaker { - cb - } else { - circuit_breaker::CircuitBreakerBuilder::default() - .build_async() - .map_err(AgentError::CircuitBreakerCreationError)? - }; - - let buffer = Buffer::new(self.buffer_size); - - Ok(UsageAgentInner { - endpoint, - buffer, - processor: OperationProcessor::new(), - client, - flush_interval: self.flush_interval, - circuit_breaker, - }) - } - pub fn build(self) -> Result { - let agent = self.build_agent()?; - Ok(Arc::new(AsyncDropper::new(agent))) - } -} - -// Target ID regexp for validation: slug format -static SLUG_REGEX: Lazy = Lazy::new(|| { - regex_automata::meta::Regex::new(r"^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+$").unwrap() -}); -// Target ID regexp for validation: UUID format -static UUID_REGEX: Lazy = Lazy::new(|| { - regex_automata::meta::Regex::new( - r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", - ) - .unwrap() -}); - -fn validate_target_id(target_id: &str) -> Result<&str, AgentError> { - let trimmed_s = target_id.trim(); - if trimmed_s.is_empty() { - Err(AgentError::InvalidTargetId("".to_string())) - } else { - if SLUG_REGEX.is_match(trimmed_s) { - return Ok(trimmed_s); - } - if UUID_REGEX.is_match(trimmed_s) { - return Ok(trimmed_s); - } - Err(AgentError::InvalidTargetId(format!( - "Invalid target_id format: '{}'. It must be either in slug format '$organizationSlug/$projectSlug/$targetSlug' or UUID format 'a0f4c605-6541-4350-8cfe-b31f21a4bf80'", - trimmed_s - ))) - } -} diff --git a/packages/libraries/sdk-rs/src/agent/mod.rs b/packages/libraries/sdk-rs/src/agent/mod.rs deleted file mode 100644 index e52fa1ad0..000000000 --- a/packages/libraries/sdk-rs/src/agent/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod buffer; -pub mod builder; -pub mod usage_agent; -pub mod utils; diff --git a/packages/libraries/sdk-rs/src/agent/usage_agent.rs b/packages/libraries/sdk-rs/src/agent/usage_agent.rs deleted file mode 100644 index 5cecea51c..000000000 --- a/packages/libraries/sdk-rs/src/agent/usage_agent.rs +++ /dev/null @@ -1,453 +0,0 @@ -use async_dropper_simple::{AsyncDrop, AsyncDropper}; -use graphql_parser::schema::Document; -use recloser::AsyncRecloser; -use reqwest_middleware::ClientWithMiddleware; -use std::{ - collections::{hash_map::Entry, HashMap}, - sync::Arc, - time::Duration, -}; -use thiserror::Error; -use tokio_util::sync::CancellationToken; - -use crate::agent::{buffer::AddStatus, utils::OperationProcessor}; -use crate::agent::{buffer::Buffer, builder::UsageAgentBuilder}; - -#[derive(Debug, Clone)] -pub struct ExecutionReport { - pub schema: Arc>, - pub client_name: Option, - pub client_version: Option, - pub timestamp: u64, - pub duration: Duration, - pub ok: bool, - pub errors: usize, - pub operation_body: String, - pub operation_name: Option, - pub persisted_document_hash: Option, -} - -typify::import_types!(schema = "./usage-report-v2.schema.json"); - -pub struct UsageAgentInner { - pub(crate) endpoint: String, - pub(crate) buffer: Buffer, - pub(crate) processor: OperationProcessor, - pub(crate) client: ClientWithMiddleware, - pub(crate) flush_interval: Duration, - pub(crate) circuit_breaker: AsyncRecloser, -} - -pub fn non_empty_string(value: Option) -> Option { - value.filter(|str| !str.is_empty()) -} - -#[derive(Error, Debug)] -pub enum AgentError { - #[error("unable to acquire lock: {0}")] - Lock(String), - #[error("unable to send report: unauthorized")] - Unauthorized, - #[error("unable to send report: no access")] - Forbidden, - #[error("unable to send report: rate limited")] - RateLimited, - #[error("missing token")] - MissingToken, - #[error("your access token requires providing a 'target_id' option.")] - MissingTargetId, - #[error("using 'target_id' with legacy tokens is not supported")] - TargetIdWithLegacyToken, - #[error("invalid token provided")] - InvalidToken, - #[error("invalid target id provided: {0}, it should be either a slug like \"$organizationSlug/$projectSlug/$targetSlug\" or an UUID")] - InvalidTargetId(String), - #[error("unable to instantiate the http client for reports sending: {0}")] - HTTPClientCreationError(reqwest::Error), - #[error("unable to create circuit breaker: {0}")] - CircuitBreakerCreationError(#[from] crate::circuit_breaker::CircuitBreakerError), - #[error("rejected by the circuit breaker")] - CircuitBreakerRejected, - #[error("unable to send report: {0}")] - Unknown(String), -} - -pub type UsageAgent = Arc>; - -#[async_trait::async_trait] -pub trait UsageAgentExt { - fn builder() -> UsageAgentBuilder { - UsageAgentBuilder::default() - } - async fn flush(&self) -> Result<(), AgentError>; - async fn start_flush_interval(&self, token: &CancellationToken); - async fn add_report(&self, execution_report: ExecutionReport) -> Result<(), AgentError>; -} - -impl UsageAgentInner { - fn produce_report(&self, reports: Vec) -> Result { - let mut report = Report { - size: 0, - map: HashMap::new(), - operations: Vec::new(), - subscription_operations: Vec::new(), - }; - - // iterate over reports and check if they are valid - for op in reports { - let operation = self.processor.process(&op.operation_body, &op.schema); - match operation { - Err(e) => { - tracing::warn!( - "Dropping operation \"{}\" (phase: PROCESSING): {}", - op.operation_name - .clone() - .or_else(|| Some("anonymous".to_string())) - .unwrap(), - e - ); - continue; - } - Ok(operation) => match operation { - Some(operation) => { - let hash = operation.hash; - - let client_name = non_empty_string(op.client_name); - let client_version = non_empty_string(op.client_version); - - let metadata: Option = - if client_name.is_some() || client_version.is_some() { - Some(Metadata { - client: Some(Client { - name: client_name.unwrap_or_default(), - version: client_version.unwrap_or_default(), - }), - }) - } else { - None - }; - report.operations.push(RequestOperation { - operation_map_key: hash.clone(), - timestamp: op.timestamp, - execution: Execution { - ok: op.ok, - /* - The conversion from u128 (from op.duration.as_nanos()) to u64 using try_into().unwrap() can panic if the duration is longer than u64::MAX nanoseconds (over 584 years). - While highly unlikely, it's safer to handle this potential overflow gracefully in library code to prevent panics. - A safe alternative is to convert the Result to an Option and provide a fallback value on failure, - effectively saturating at u64::MAX. - */ - duration: op - .duration - .as_nanos() - .try_into() - .ok() - .unwrap_or(u64::MAX), - errors_total: op.errors.try_into().unwrap(), - }, - persisted_document_hash: op - .persisted_document_hash - .map(PersistedDocumentHash), - metadata, - }); - if let Entry::Vacant(e) = report.map.entry(ReportMapKey(hash)) { - e.insert(OperationMapRecord { - operation: operation.operation, - operation_name: non_empty_string(op.operation_name), - fields: operation.coordinates, - }); - } - report.size += 1; - } - None => { - tracing::debug!( - "Dropping operation (phase: PROCESSING): probably introspection query" - ); - } - }, - } - } - - Ok(report) - } - - async fn send_report(&self, report: Report) -> Result<(), AgentError> { - if report.size == 0 { - return Ok(()); - } - // Based on https://the-guild.dev/graphql/hive/docs/specs/usage-reports#data-structure - let resp_fut = self.client.post(&self.endpoint).json(&report).send(); - - let resp = self - .circuit_breaker - .call(resp_fut) - .await - .map_err(|e| match e { - recloser::Error::Inner(e) => AgentError::Unknown(e.to_string()), - recloser::Error::Rejected => AgentError::CircuitBreakerRejected, - })?; - - match resp.status() { - reqwest::StatusCode::OK => Ok(()), - reqwest::StatusCode::UNAUTHORIZED => Err(AgentError::Unauthorized), - reqwest::StatusCode::FORBIDDEN => Err(AgentError::Forbidden), - reqwest::StatusCode::TOO_MANY_REQUESTS => Err(AgentError::RateLimited), - _ => Err(AgentError::Unknown(format!( - "({}) {}", - resp.status(), - resp.text().await.unwrap_or_default() - ))), - } - } - - async fn handle_drained(&self, drained: Vec) -> Result<(), AgentError> { - if drained.is_empty() { - return Ok(()); - } - let report = self.produce_report(drained)?; - self.send_report(report).await - } - - async fn flush(&self) -> Result<(), AgentError> { - let execution_reports = self.buffer.drain().await; - - self.handle_drained(execution_reports).await?; - - Ok(()) - } -} - -#[async_trait::async_trait] -impl UsageAgentExt for UsageAgent { - async fn flush(&self) -> Result<(), AgentError> { - self.inner().flush().await - } - - async fn start_flush_interval(&self, token: &CancellationToken) { - loop { - tokio::time::sleep(self.inner().flush_interval).await; - if token.is_cancelled() { - println!("Shutting down."); - return; - } - self.flush() - .await - .unwrap_or_else(|e| tracing::error!("Failed to flush usage reports: {}", e)); - } - } - - async fn add_report(&self, execution_report: ExecutionReport) -> Result<(), AgentError> { - if let AddStatus::Full { drained } = self.inner().buffer.add(execution_report).await { - self.inner().handle_drained(drained).await?; - } - - Ok(()) - } -} - -#[async_trait::async_trait] -impl AsyncDrop for UsageAgentInner { - async fn async_drop(&mut self) { - if let Err(e) = self.flush().await { - tracing::error!("Failed to flush usage reports during drop: {}", e); - } - } -} - -#[cfg(test)] -mod tests { - use std::{sync::Arc, time::Duration}; - - use graphql_parser::{parse_query, parse_schema}; - use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; - - use crate::agent::usage_agent::{ExecutionReport, Report, UsageAgent, UsageAgentExt}; - - const CONTENT_TYPE_VALUE: &'static str = "application/json"; - const GRAPHQL_CLIENT_NAME: &'static str = "Hive Client"; - const GRAPHQL_CLIENT_VERSION: &'static str = "1.0.0"; - - #[tokio::test(flavor = "multi_thread")] - async fn should_send_data_to_hive() -> Result<(), Box> { - let token = "Token"; - - let mut server = mockito::Server::new_async().await; - - let server_url = server.url(); - - let timestamp = 1625247600; - let duration = Duration::from_millis(20); - let user_agent = "hive-router-sdk-test"; - - let mock = server - .mock("POST", "/200") - .match_header(AUTHORIZATION, format!("Bearer {}", token).as_str()) - .match_header(CONTENT_TYPE, CONTENT_TYPE_VALUE) - .match_header(USER_AGENT, user_agent) - .match_header("X-Usage-API-Version", "2") - .match_request(move |request| { - let request_body = request.body().expect("Failed to extract body"); - let report: Report = serde_json::from_slice(request_body) - .expect("Failed to parse request body as JSON"); - assert_eq!(report.size, 1); - let record = report.map.values().next().expect("No operation record"); - // operation - assert!(record.operation.contains("mutation deleteProject")); - assert_eq!(record.operation_name.as_deref(), Some("deleteProject")); - // fields - let expected_fields = vec![ - "Mutation.deleteProject", - "Mutation.deleteProject.selector", - "DeleteProjectPayload.selector", - "ProjectSelector.organization", - "ProjectSelector.project", - "DeleteProjectPayload.deletedProject", - "Project.id", - "Project.cleanId", - "Project.name", - "Project.type", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - "ProjectType.SINGLE", - "ProjectType.CUSTOM", - "ProjectSelectorInput.organization", - "ID", - "ProjectSelectorInput.project", - ]; - for field in &expected_fields { - assert!( - record.fields.contains(&field.to_string()), - "Missing field: {}", - field - ); - } - assert_eq!( - record.fields.len(), - expected_fields.len(), - "Unexpected number of fields" - ); - - // Operations - let operations = report.operations; - assert_eq!(operations.len(), 1); // one operation - - let operation = &operations[0]; - let key = report.map.keys().next().expect("No operation key"); - assert_eq!(operation.operation_map_key, key.0); - assert_eq!(operation.timestamp, timestamp); - assert_eq!(operation.execution.duration, duration.as_nanos() as u64); - assert_eq!(operation.execution.ok, true); - assert_eq!(operation.execution.errors_total, 0); - true - }) - .expect(1) - .with_status(200) - .create_async() - .await; - let schema: graphql_tools::static_graphql::schema::Document = parse_schema( - r#" - type Query { - project(selector: ProjectSelectorInput!): Project - projectsByType(type: ProjectType!): [Project!]! - projects(filter: FilterInput): [Project!]! - } - - type Mutation { - deleteProject(selector: ProjectSelectorInput!): DeleteProjectPayload! - } - - input ProjectSelectorInput { - organization: ID! - project: ID! - } - - input FilterInput { - type: ProjectType - pagination: PaginationInput - } - - input PaginationInput { - limit: Int - offset: Int - } - - type ProjectSelector { - organization: ID! - project: ID! - } - - type DeleteProjectPayload { - selector: ProjectSelector! - deletedProject: Project! - } - - type Project { - id: ID! - cleanId: ID! - name: String! - type: ProjectType! - buildUrl: String - validationUrl: String - } - - enum ProjectType { - FEDERATION - STITCHING - SINGLE - CUSTOM - } - "#, - )?; - - let op: graphql_tools::static_graphql::query::Document = parse_query( - r#" - mutation deleteProject($selector: ProjectSelectorInput!) { - deleteProject(selector: $selector) { - selector { - organization - project - } - deletedProject { - ...ProjectFields - } - } - } - - fragment ProjectFields on Project { - id - cleanId - name - type - } - "#, - )?; - - // Testing async drop - { - let usage_agent = UsageAgent::builder() - .token(token.into()) - .endpoint(format!("{}/200", server_url)) - .user_agent(user_agent.into()) - .build()?; - - usage_agent - .add_report(ExecutionReport { - schema: Arc::new(schema), - operation_body: op.to_string(), - operation_name: Some("deleteProject".to_string()), - client_name: Some(GRAPHQL_CLIENT_NAME.to_string()), - client_version: Some(GRAPHQL_CLIENT_VERSION.to_string()), - timestamp, - duration, - ok: true, - errors: 0, - persisted_document_hash: None, - }) - .await?; - } - - mock.assert_async().await; - - Ok(()) - } -} diff --git a/packages/libraries/sdk-rs/src/agent/utils.rs b/packages/libraries/sdk-rs/src/agent/utils.rs deleted file mode 100644 index d806f7a93..000000000 --- a/packages/libraries/sdk-rs/src/agent/utils.rs +++ /dev/null @@ -1,2450 +0,0 @@ -use anyhow::anyhow; -use anyhow::Error; -use graphql_parser::schema::InputObjectType; -use graphql_tools::ast::ext::SchemaDocumentExtension; -use graphql_tools::ast::FieldByNameExtension; -use graphql_tools::ast::TypeDefinitionExtension; -use graphql_tools::ast::TypeExtension; -use moka::sync::Cache; -use std::cmp::Ordering; -use std::collections::BTreeMap; -use std::collections::HashMap; -use std::collections::HashSet; - -pub use graphql_parser::minify_query; -use graphql_parser::parse_query; -use graphql_parser::query::{ - Definition, Directive, Document, Field, FragmentDefinition, Number, OperationDefinition, - Selection, SelectionSet, Text, Type, Value, VariableDefinition, -}; -use graphql_parser::schema::{Document as SchemaDocument, TypeDefinition}; -use graphql_tools::ast::{ - visit_document, OperationTransformer, OperationVisitor, OperationVisitorContext, Transformed, - TransformedValue, -}; - -struct SchemaCoordinatesContext<'a> { - pub schema_coordinates: HashSet, - pub used_input_fields: HashSet<&'a str>, - pub input_values_provided: HashMap, - pub used_variables: HashSet<&'a str>, - pub variables_with_defaults: HashSet<&'a str>, - error: Option, -} - -impl SchemaCoordinatesContext<'_> { - fn is_corrupted(&self) -> bool { - self.error.is_some() - } -} - -pub fn collect_schema_coordinates( - document: &Document<'static, String>, - schema: &SchemaDocument<'static, String>, -) -> Result, Error> { - let mut ctx = SchemaCoordinatesContext { - schema_coordinates: HashSet::new(), - used_input_fields: HashSet::new(), - input_values_provided: HashMap::new(), - used_variables: HashSet::new(), - variables_with_defaults: HashSet::new(), - error: None, - }; - let mut visit_context = OperationVisitorContext::new(document, schema); - let mut visitor = SchemaCoordinatesVisitor { - visited_input_object_types: HashSet::new(), - }; - - visit_document(&mut visitor, document, &mut visit_context, &mut ctx); - - if let Some(error) = ctx.error { - Err(error) - } else { - for type_name in ctx.used_input_fields { - visitor.collect_nested_input_type(schema, type_name, &mut ctx.schema_coordinates); - } - - Ok(ctx.schema_coordinates) - } -} - -fn is_builtin_scalar(type_name: &str) -> bool { - matches!(type_name, "String" | "Int" | "Float" | "Boolean" | "ID") -} - -fn mark_as_used(ctx: &mut SchemaCoordinatesContext, id: &str) { - if let Some(count) = ctx.input_values_provided.get_mut(id) { - if *count > 0 { - *count -= 1; - ctx.schema_coordinates.insert(format!("{}!", id)); - } - } - ctx.schema_coordinates.insert(id.to_string()); -} - -fn count_input_value_provided(ctx: &mut SchemaCoordinatesContext, id: &str) { - let counter = ctx.input_values_provided.entry(id.to_string()).or_insert(0); - *counter += 1; -} - -fn value_exists(v: &Value) -> bool { - !matches!(v, Value::Null) -} - -struct SchemaCoordinatesVisitor<'a> { - visited_input_object_types: HashSet<&'a str>, -} - -impl<'a> SchemaCoordinatesVisitor<'a> { - fn process_default_value( - info: &OperationVisitorContext<'a>, - ctx: &mut SchemaCoordinatesContext, - type_name: &str, - value: &Value, - ) { - match value { - Value::Object(obj) => { - if let Some(TypeDefinition::InputObject(input_obj)) = - info.schema.type_by_name(type_name) - { - for (field_name, field_value) in obj { - if let Some(field_def) = - input_obj.fields.iter().find(|f| &f.name == field_name) - { - let coordinate = format!("{}.{}", type_name, field_name); - - // Since a value is provided in the default, mark it with ! - ctx.schema_coordinates.insert(format!("{}!", coordinate)); - ctx.schema_coordinates.insert(coordinate); - - // Recursively process nested objects - let field_type_name = Self::resolve_type_name(&field_def.value_type); - Self::process_default_value(info, ctx, field_type_name, field_value); - } - } - } - } - Value::List(values) => { - for val in values { - Self::process_default_value(info, ctx, type_name, val); - } - } - Value::Enum(enum_value) => { - let enum_coordinate = format!("{}.{}", type_name, enum_value); - ctx.schema_coordinates.insert(enum_coordinate); - } - _ => { - // For scalar values, the type is already collected in variable definition - } - } - } - - fn resolve_type_name(t: &'a Type) -> &'a str { - match t { - Type::NamedType(value) => value.as_str(), - Type::ListType(t) => Self::resolve_type_name(t), - Type::NonNullType(t) => Self::resolve_type_name(t), - } - } - - fn resolve_references( - &self, - schema: &'a SchemaDocument<'static, String>, - type_name: &'a str, - ) -> Option> { - let mut visited_types = Vec::new(); - Self::_resolve_references(schema, type_name, &mut visited_types); - Some(visited_types) - } - - fn _resolve_references( - schema: &'a SchemaDocument<'static, String>, - type_name: &'a str, - visited_types: &mut Vec<&'a str>, - ) { - if visited_types.contains(&type_name) { - return; - } - - visited_types.push(type_name); - - let named_type = schema.type_by_name(type_name); - - if let Some(TypeDefinition::InputObject(input_type)) = named_type { - for field in &input_type.fields { - let field_type = Self::resolve_type_name(&field.value_type); - Self::_resolve_references(schema, field_type, visited_types); - } - } - } - - fn collect_nested_input_type( - &mut self, - schema: &'a SchemaDocument<'static, String>, - input_type_name: &'a str, - coordinates: &mut HashSet, - ) { - if let Some(input_type_def) = schema.type_by_name(input_type_name) { - match input_type_def { - TypeDefinition::Scalar(scalar_def) => { - coordinates.insert(scalar_def.name.clone()); - } - TypeDefinition::InputObject(nested_input_type) => { - self.collect_nested_input_fields(schema, nested_input_type, coordinates); - } - TypeDefinition::Enum(enum_type) => { - for value in &enum_type.values { - coordinates.insert(format!("{}.{}", enum_type.name, value.name)); - } - } - _ => {} - } - } else if is_builtin_scalar(input_type_name) { - // Handle built-in scalars - coordinates.insert(input_type_name.to_string()); - } - } - - fn collect_nested_input_fields( - &mut self, - schema: &'a SchemaDocument<'static, String>, - input_type: &'a InputObjectType<'static, String>, - coordinates: &mut HashSet, - ) { - if self - .visited_input_object_types - .contains(&input_type.name.as_str()) - { - return; - } - self.visited_input_object_types - .insert(input_type.name.as_str()); - for field in &input_type.fields { - let field_coordinate = format!("{}.{}", input_type.name, field.name); - coordinates.insert(field_coordinate); - - let field_type_name = field.value_type.inner_type(); - - self.collect_nested_input_type(schema, field_type_name, coordinates); - } - } -} - -impl<'a> OperationVisitor<'a, SchemaCoordinatesContext<'a>> for SchemaCoordinatesVisitor<'a> { - fn enter_variable_value( - &mut self, - _info: &mut OperationVisitorContext<'a>, - ctx: &mut SchemaCoordinatesContext<'a>, - name: &'a str, - ) { - ctx.used_variables.insert(name); - } - - fn enter_field( - &mut self, - info: &mut OperationVisitorContext<'a>, - ctx: &mut SchemaCoordinatesContext, - field: &Field<'static, String>, - ) { - if ctx.is_corrupted() { - return; - } - - let field_name = field.name.to_string(); - - if let Some(parent_type) = info.current_parent_type() { - let parent_name = parent_type.name(); - - ctx.schema_coordinates - .insert(format!("{}.{}", parent_name, field_name)); - - if let Some(field_def) = parent_type.field_by_name(&field_name) { - // if field's type is an enum, we need to collect all possible values - let field_output_type = info.schema.type_by_name(field_def.field_type.inner_type()); - if let Some(TypeDefinition::Enum(enum_type)) = field_output_type { - for value in &enum_type.values { - ctx.schema_coordinates.insert(format!( - "{}.{}", - enum_type.name.as_str(), - value.name - )); - } - } - } - } else { - ctx.error = Some(anyhow!( - "Unable to find parent type of '{}' field", - field.name - )) - } - } - - fn enter_variable_definition( - &mut self, - info: &mut OperationVisitorContext<'a>, - ctx: &mut SchemaCoordinatesContext<'a>, - var: &'a graphql_tools::static_graphql::query::VariableDefinition, - ) { - if ctx.is_corrupted() { - return; - } - - if var.default_value.is_some() { - ctx.variables_with_defaults.insert(var.name.as_str()); - } - - let type_name = Self::resolve_type_name(&var.var_type); - - if let Some(inner_types) = self.resolve_references(info.schema, type_name) { - for inner_type in inner_types { - ctx.used_input_fields.insert(inner_type); - } - } - - ctx.used_input_fields.insert(type_name); - - if let Some(default_value) = &var.default_value { - Self::process_default_value(info, ctx, type_name, default_value); - } - } - - fn enter_argument( - &mut self, - info: &mut OperationVisitorContext<'a>, - ctx: &mut SchemaCoordinatesContext<'a>, - arg: &(String, Value<'static, String>), - ) { - if ctx.is_corrupted() { - return; - } - - if info.current_parent_type().is_none() { - ctx.error = Some(anyhow!( - "Unable to find parent type of '{}' argument", - arg.0.clone() - )); - return; - } - - let parent_type = info.current_parent_type().unwrap(); - let type_name = parent_type.name(); - let field = info.current_field(); - - if let Some(field) = field { - let field_name = field.name.clone(); - let (arg_name, arg_value) = arg; - - let coordinate = format!("{type_name}.{field_name}.{arg_name}"); - - let has_value = match arg_value { - Value::Null => false, - Value::Variable(var_name) => { - ctx.variables_with_defaults.contains(var_name.as_str()) - } - _ => true, - }; - - if has_value { - count_input_value_provided(ctx, &coordinate); - } - mark_as_used(ctx, &coordinate); - if let Some(field_def) = parent_type.field_by_name(&field_name) { - if let Some(arg_def) = field_def.arguments.iter().find(|a| &a.name == arg_name) { - let arg_type_name = Self::resolve_type_name(&arg_def.value_type); - - match arg_value { - Value::Enum(value) => { - let value_str: String = value.to_string(); - ctx.schema_coordinates - .insert(format!("{arg_type_name}.{value_str}").to_string()); - } - Value::List(_) => { - // handled by enter_list_value - } - Value::Object(_) => { - // Only collect scalar type if it's actually a custom scalar - // receiving an object value - if let Some(TypeDefinition::Scalar(_)) = - info.schema.type_by_name(arg_type_name) - { - ctx.schema_coordinates.insert(arg_type_name.to_string()); - } - // Otherwise handled by enter_object_value - } - Value::Variable(_) => { - // Variables are handled by enter_variable_definition - } - _ => { - // For literal scalar values, collect the scalar type - // But only for actual scalars, not enum/input types - if is_builtin_scalar(arg_type_name) { - ctx.schema_coordinates.insert(arg_type_name.to_string()); - } else if let Some(TypeDefinition::Scalar(_)) = - info.schema.type_by_name(arg_type_name) - { - ctx.schema_coordinates.insert(arg_type_name.to_string()); - } - } - } - } - } - } - } - - fn enter_list_value( - &mut self, - info: &mut OperationVisitorContext<'a>, - ctx: &mut SchemaCoordinatesContext, - values: &Vec>, - ) { - if ctx.is_corrupted() { - return; - } - - if let Some(input_type) = info.current_input_type() { - let coordinate = input_type.name().to_string(); - for value in values { - match value { - Value::Enum(value) => { - let value_str = value.to_string(); - ctx.schema_coordinates - .insert(format!("{}.{}", coordinate, value_str)); - } - Value::Object(_) => { - // object fields are handled by enter_object_value - } - Value::List(_) => { - // handled by enter_list_value - } - Value::Variable(_) => { - // handled by enter_variable_definition - } - _ => { - // For scalar literals in lists, collect the scalar type - if is_builtin_scalar(&coordinate) { - ctx.schema_coordinates.insert(coordinate.clone()); - } else if let Some(TypeDefinition::Scalar(_)) = - info.schema.type_by_name(&coordinate) - { - ctx.schema_coordinates.insert(coordinate.clone()); - } - } - } - } - } - } - - fn enter_object_value( - &mut self, - info: &mut OperationVisitorContext<'a>, - ctx: &mut SchemaCoordinatesContext, - object_value: &BTreeMap, - ) { - if let Some(TypeDefinition::InputObject(input_object_def)) = info.current_input_type() { - object_value.iter().for_each(|(name, value)| { - if let Some(field) = input_object_def - .fields - .iter() - .find(|field| field.name.eq(name)) - { - let coordinate = format!("{}.{}", input_object_def.name, field.name); - - let has_value = match value { - Value::Variable(var_name) => { - ctx.variables_with_defaults.contains(var_name.as_str()) - } - _ => value_exists(value), - }; - - ctx.schema_coordinates.insert(coordinate.clone()); - if has_value { - ctx.schema_coordinates.insert(format!("{coordinate}!")); - } - - mark_as_used(ctx, &coordinate); - - let field_type_name = field.value_type.inner_type(); - - match value { - Value::Enum(value) => { - let value_str = value.to_string(); - ctx.schema_coordinates - .insert(format!("{field_type_name}.{value_str}").to_string()); - } - Value::List(_) => { - // handled by enter_list_value - } - Value::Object(_) => { - // Only collect scalar type if it's a custom scalar receiving object - if let Some(TypeDefinition::Scalar(_)) = - info.schema.type_by_name(field_type_name) - { - ctx.schema_coordinates.insert(field_type_name.to_string()); - } - // Otherwise handled by enter_object_value recursively - } - Value::Variable(_) => { - // Variables handled by enter_variable_definition - // Only collect scalar types for variables, not enum/input types - if is_builtin_scalar(field_type_name) { - ctx.schema_coordinates.insert(field_type_name.to_string()); - } else if let Some(TypeDefinition::Scalar(_)) = - info.schema.type_by_name(field_type_name) - { - ctx.schema_coordinates.insert(field_type_name.to_string()); - } - } - Value::Null => { - // When a field has a null value, we should still collect - // all nested coordinates for input object types - if let Some(TypeDefinition::InputObject(nested_input_obj)) = - info.schema.type_by_name(field_type_name) - { - self.collect_nested_input_fields( - info.schema, - nested_input_obj, - &mut ctx.schema_coordinates, - ); - } - } - _ => { - // For literal scalar values, only collect actual scalar types - if is_builtin_scalar(field_type_name) { - ctx.schema_coordinates.insert(field_type_name.to_string()); - } else if let Some(TypeDefinition::Scalar(_)) = - info.schema.type_by_name(field_type_name) - { - ctx.schema_coordinates.insert(field_type_name.to_string()); - } - } - } - } - }); - } - } -} - -struct StripLiteralsTransformer {} - -impl<'a, T: Text<'a> + Clone> OperationTransformer<'a, T> for StripLiteralsTransformer { - fn transform_value(&mut self, node: &Value<'a, T>) -> TransformedValue> { - match node { - Value::Float(_) => TransformedValue::Replace(Value::Float(0.0)), - Value::Int(_) => TransformedValue::Replace(Value::Int(Number::from(0))), - Value::String(_) => TransformedValue::Replace(Value::String(String::from(""))), - Value::Variable(_) => TransformedValue::Keep, - Value::Boolean(_) => TransformedValue::Keep, - Value::Null => TransformedValue::Keep, - Value::Enum(_) => TransformedValue::Keep, - Value::List(val) => { - let items: Vec> = val - .iter() - .map(|item| self.transform_value(item).replace_or_else(|| item.clone())) - .collect(); - - TransformedValue::Replace(Value::List(items)) - } - Value::Object(fields) => { - let fields: BTreeMap> = fields - .iter() - .map(|field| { - let (name, value) = field; - let new_value = self - .transform_value(value) - .replace_or_else(|| value.clone()); - (name.clone(), new_value) - }) - .collect(); - - TransformedValue::Replace(Value::Object(fields)) - } - } - } - - fn transform_field( - &mut self, - field: &graphql_parser::query::Field<'a, T>, - ) -> Transformed> { - let selection_set = self.transform_selection_set(&field.selection_set); - let arguments = self.transform_arguments(&field.arguments); - let directives = self.transform_directives(&field.directives); - - Transformed::Replace(Selection::Field(Field { - arguments: arguments.replace_or_else(|| field.arguments.clone()), - directives: directives.replace_or_else(|| field.directives.clone()), - selection_set: SelectionSet { - items: selection_set.replace_or_else(|| field.selection_set.items.clone()), - span: field.selection_set.span, - }, - position: field.position, - alias: None, - name: field.name.clone(), - })) - } -} - -#[derive(Hash, Eq, PartialEq, Clone, Copy)] -pub struct PointerAddress(usize); - -impl PointerAddress { - pub fn new(ptr: &T) -> Self { - let ptr_address: usize = unsafe { std::mem::transmute(ptr) }; - Self(ptr_address) - } -} - -type Seen<'s, T> = HashMap>>; - -pub struct SortSelectionsTransform<'s, T: Text<'s> + Clone> { - seen: Seen<'s, T>, -} - -impl<'s, T: Text<'s> + Clone> Default for SortSelectionsTransform<'s, T> { - fn default() -> Self { - Self::new() - } -} - -impl<'s, T: Text<'s> + Clone> SortSelectionsTransform<'s, T> { - pub fn new() -> Self { - Self { - seen: Default::default(), - } - } -} - -impl<'s, T: Text<'s> + Clone> OperationTransformer<'s, T> for SortSelectionsTransform<'s, T> { - fn transform_document( - &mut self, - document: &Document<'s, T>, - ) -> TransformedValue> { - let mut next_definitions = self - .transform_list(&document.definitions, Self::transform_definition) - .replace_or_else(|| document.definitions.to_vec()); - next_definitions.sort_unstable_by(|a, b| self.compare_definitions(a, b)); - TransformedValue::Replace(Document { - definitions: next_definitions, - }) - } - - fn transform_selection_set( - &mut self, - selections: &SelectionSet<'s, T>, - ) -> TransformedValue>> { - let mut next_selections = self - .transform_list(&selections.items, Self::transform_selection) - .replace_or_else(|| selections.items.to_vec()); - next_selections.sort_unstable_by(|a, b| self.compare_selections(a, b)); - TransformedValue::Replace(next_selections) - } - - fn transform_directives( - &mut self, - directives: &[Directive<'s, T>], - ) -> TransformedValue>> { - let mut next_directives = self - .transform_list(directives, Self::transform_directive) - .replace_or_else(|| directives.to_vec()); - next_directives.sort_unstable_by(|a, b| self.compare_directives(a, b)); - TransformedValue::Replace(next_directives) - } - - fn transform_arguments( - &mut self, - arguments: &[(T::Value, Value<'s, T>)], - ) -> TransformedValue)>> { - let mut next_arguments = self - .transform_list(arguments, Self::transform_argument) - .replace_or_else(|| arguments.to_vec()); - next_arguments.sort_unstable_by(|a, b| self.compare_arguments(a, b)); - TransformedValue::Replace(next_arguments) - } - - fn transform_variable_definitions( - &mut self, - variable_definitions: &Vec>, - ) -> TransformedValue>> { - let mut next_variable_definitions = self - .transform_list(variable_definitions, Self::transform_variable_definition) - .replace_or_else(|| variable_definitions.to_vec()); - next_variable_definitions.sort_unstable_by(|a, b| self.compare_variable_definitions(a, b)); - TransformedValue::Replace(next_variable_definitions) - } - - fn transform_fragment( - &mut self, - fragment: &FragmentDefinition<'s, T>, - ) -> Transformed> { - let mut directives = fragment.directives.clone(); - directives.sort_unstable_by_key(|var| var.name.clone()); - - let selections = self.transform_selection_set(&fragment.selection_set); - - Transformed::Replace(FragmentDefinition { - selection_set: SelectionSet { - items: selections.replace_or_else(|| fragment.selection_set.items.clone()), - span: fragment.selection_set.span, - }, - directives, - name: fragment.name.clone(), - position: fragment.position, - type_condition: fragment.type_condition.clone(), - }) - } - - fn transform_selection( - &mut self, - selection: &Selection<'s, T>, - ) -> Transformed> { - match selection { - Selection::InlineFragment(selection) => { - let key = PointerAddress::new(selection); - if let Some(prev) = self.seen.get(&key) { - return prev.clone(); - } - let transformed = self.transform_inline_fragment(selection); - self.seen.insert(key, transformed.clone()); - transformed - } - Selection::Field(field) => { - let key = PointerAddress::new(field); - if let Some(prev) = self.seen.get(&key) { - return prev.clone(); - } - let transformed = self.transform_field(field); - self.seen.insert(key, transformed.clone()); - transformed - } - Selection::FragmentSpread(_) => Transformed::Keep, - } - } -} - -impl<'s, T: Text<'s> + Clone> SortSelectionsTransform<'s, T> { - fn compare_definitions(&self, a: &Definition<'s, T>, b: &Definition<'s, T>) -> Ordering { - match (a, b) { - // Keep operations as they are - (Definition::Operation(_), Definition::Operation(_)) => Ordering::Equal, - // Sort fragments by name - (Definition::Fragment(a), Definition::Fragment(b)) => a.name.cmp(&b.name), - // Operation -> Fragment - _ => definition_kind_ordering(a).cmp(&definition_kind_ordering(b)), - } - } - - fn compare_selections(&self, a: &Selection<'s, T>, b: &Selection<'s, T>) -> Ordering { - match (a, b) { - (Selection::Field(a), Selection::Field(b)) => a.name.cmp(&b.name), - (Selection::FragmentSpread(a), Selection::FragmentSpread(b)) => { - a.fragment_name.cmp(&b.fragment_name) - } - _ => { - let a_ordering = selection_kind_ordering(a); - let b_ordering = selection_kind_ordering(b); - a_ordering.cmp(&b_ordering) - } - } - } - fn compare_directives(&self, a: &Directive<'s, T>, b: &Directive<'s, T>) -> Ordering { - a.name.cmp(&b.name) - } - fn compare_arguments( - &self, - a: &(T::Value, Value<'s, T>), - b: &(T::Value, Value<'s, T>), - ) -> Ordering { - a.0.cmp(&b.0) - } - fn compare_variable_definitions( - &self, - a: &VariableDefinition<'s, T>, - b: &VariableDefinition<'s, T>, - ) -> Ordering { - a.name.cmp(&b.name) - } -} - -/// Assigns an order to different variants of Selection. -fn selection_kind_ordering<'s, T: Text<'s>>(selection: &Selection<'s, T>) -> u8 { - match selection { - Selection::FragmentSpread(_) => 1, - Selection::InlineFragment(_) => 2, - Selection::Field(_) => 3, - } -} - -/// Assigns an order to different variants of Definition -fn definition_kind_ordering<'a, T: Text<'a>>(definition: &Definition<'a, T>) -> u8 { - match definition { - Definition::Operation(_) => 1, - Definition::Fragment(_) => 2, - } -} - -pub fn normalize_operation<'a>(operation_document: &Document<'a, String>) -> Document<'a, String> { - let mut strip_literals_transformer = StripLiteralsTransformer {}; - let normalized = strip_literals_transformer - .transform_document(operation_document) - .replace_or_else(|| operation_document.clone()); - - SortSelectionsTransform::new() - .transform_document(&normalized) - .replace_or_else(|| normalized.clone()) -} - -#[derive(Clone)] -pub struct ProcessedOperation { - pub operation: String, - pub hash: String, - pub coordinates: Vec, -} - -pub struct OperationProcessor { - cache: Cache>, -} - -impl Default for OperationProcessor { - fn default() -> Self { - Self::new() - } -} - -impl OperationProcessor { - pub fn new() -> OperationProcessor { - OperationProcessor { - cache: Cache::new(1000), - } - } - - pub fn process( - &self, - query: &str, - schema: &SchemaDocument<'static, String>, - ) -> Result, String> { - if self.cache.contains_key(query) { - let entry = self - .cache - .get(query) - .expect("Unable to acquire Cache in OperationProcessor.process"); - Ok(entry.clone()) - } else { - let result = self.transform(query, schema)?; - self.cache.insert(query.to_string(), result.clone()); - Ok(result) - } - } - - fn transform( - &self, - operation: &str, - schema: &SchemaDocument<'static, String>, - ) -> Result, String> { - let parsed = parse_query(operation) - .map_err(|e| e.to_string())? - .into_static(); - - let is_introspection = parsed.definitions.iter().find(|def| match def { - Definition::Operation(OperationDefinition::Query(query)) => query - .selection_set - .items - .iter() - .any(|selection| match selection { - Selection::Field(field) => field.name == "__schema" || field.name == "__type", - _ => false, - }), - _ => false, - }); - - if is_introspection.is_some() { - return Ok(None); - } - - let schema_coordinates_result = - collect_schema_coordinates(&parsed, schema).map_err(|e| e.to_string())?; - - let schema_coordinates: Vec = Vec::from_iter(schema_coordinates_result); - - let normalized = normalize_operation(&parsed); - - let printed = minify_query(format!("{}", normalized.clone())).map_err(|e| e.to_string())?; - let hash = format!("{:x}", md5::compute(printed.clone())); - - Ok(Some(ProcessedOperation { - operation: printed, - hash, - coordinates: schema_coordinates, - })) - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use graphql_parser::parse_query; - use graphql_parser::parse_schema; - - use super::collect_schema_coordinates; - - const SCHEMA_SDL: &str = " - type Query { - project(selector: ProjectSelectorInput!): Project - projectsByType(type: ProjectType!): [Project!]! - projectsByTypes(types: [ ProjectType!]!): [Project!]! - projects(filter: FilterInput, and: [FilterInput!]): [Project!]! - projectsByMetadata(metadata: JSON): [Project!]! - } - - type Mutation { - deleteProject(selector: ProjectSelectorInput!): DeleteProjectPayload! - } - - input ProjectSelectorInput { - organization: ID! - project: ID! - } - - input FilterInput { - type: ProjectType - pagination: PaginationInput - order: [ProjectOrderByInput!] - metadata: JSON - } - - input PaginationInput { - limit: Int - offset: Int - } - - input ProjectOrderByInput { - field: String! - direction: OrderDirection - } - - enum OrderDirection { - ASC - DESC - } - - type ProjectSelector { - organization: ID! - project: ID! - } - - type DeleteProjectPayload { - selector: ProjectSelector! - deletedProject: Project! - } - - type Project { - id: ID! - cleanId: ID! - name: String! - type: ProjectType! - buildUrl: String - validationUrl: String - } - - enum ProjectType { - FEDERATION - STITCHING - SINGLE - } - - scalar JSON - "; - - #[test] - fn basic_test() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - - let document = parse_query::( - " - mutation deleteProjectOperation($selector: ProjectSelectorInput!) { - deleteProject(selector: $selector) { - selector { - organization - project - } - deletedProject { - ...ProjectFields - } - } - } - fragment ProjectFields on Project { - id - cleanId - name - type - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Mutation.deleteProject", - "Mutation.deleteProject.selector", - "DeleteProjectPayload.selector", - "ProjectSelector.organization", - "ProjectSelector.project", - "DeleteProjectPayload.deletedProject", - "ID", - "Project.id", - "Project.cleanId", - "Project.name", - "Project.type", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - "ProjectType.SINGLE", - "ProjectSelectorInput.organization", - "ProjectSelectorInput.project", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn entire_input() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query projects($filter: FilterInput) { - projects(filter: $filter) { - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Project.name", - "FilterInput.type", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - "ProjectType.SINGLE", - "FilterInput.pagination", - "PaginationInput.limit", - "Int", - "PaginationInput.offset", - "FilterInput.metadata", - "FilterInput.order", - "ProjectOrderByInput.field", - "String", - "ProjectOrderByInput.direction", - "OrderDirection.ASC", - "OrderDirection.DESC", - "JSON", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn entire_input_list() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query projects($filter: FilterInput) { - projects(and: $filter) { - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.and", - "Project.name", - "FilterInput.type", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - "ProjectType.SINGLE", - "FilterInput.pagination", - "FilterInput.metadata", - "PaginationInput.limit", - "Int", - "PaginationInput.offset", - "FilterInput.order", - "ProjectOrderByInput.field", - "String", - "ProjectOrderByInput.direction", - "OrderDirection.ASC", - "OrderDirection.DESC", - "JSON", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn entire_input_and_enum_value() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($pagination: PaginationInput) { - projects(and: { pagination: $pagination, type: FEDERATION }) { - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.and", - "Query.projects.and!", - "Project.name", - "PaginationInput.limit", - "Int", - "PaginationInput.offset", - "FilterInput.pagination", - "FilterInput.type", - "FilterInput.type!", - "ProjectType.FEDERATION", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn enum_value_list() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects { - projectsByTypes(types: [FEDERATION, STITCHING]) { - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projectsByTypes", - "Query.projectsByTypes.types", - "Query.projectsByTypes.types!", - "Project.name", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn enums_and_scalars_input() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($limit: Int!, $type: ProjectType!) { - projects(filter: { pagination: { limit: $limit }, type: $type }) { - id - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Query.projects.filter!", - "Project.id", - "Int", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - "ProjectType.SINGLE", - "FilterInput.pagination", - "FilterInput.pagination!", - "FilterInput.type", - "PaginationInput.limit", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn hard_coded_scalars_input() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - { - projects(filter: { pagination: { limit: 20 } }) { - id - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Query.projects.filter!", - "Project.id", - "FilterInput.pagination", - "FilterInput.pagination!", - "Int", - "PaginationInput.limit", - "PaginationInput.limit!", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn enum_values_object_field() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($limit: Int!) { - projects(filter: { pagination: { limit: $limit }, type: FEDERATION }) { - id - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Query.projects.filter!", - "Project.id", - "Int", - "FilterInput.pagination", - "FilterInput.pagination!", - "FilterInput.type", - "FilterInput.type!", - "PaginationInput.limit", - "ProjectType.FEDERATION", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn enum_list_inline() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects { - projectsByTypes(types: [FEDERATION]) { - id - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projectsByTypes", - "Query.projectsByTypes.types", - "Query.projectsByTypes.types!", - "Project.id", - "ProjectType.FEDERATION", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn enum_list_variable() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document_inline = parse_query::( - " - query getProjects($types: [ProjectType!]!) { - projectsByTypes(types: $types) { - id - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document_inline, &schema).unwrap(); - - let expected = vec![ - "Query.projectsByTypes", - "Query.projectsByTypes.types", - "Project.id", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - "ProjectType.SINGLE", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn enum_values_argument() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects { - projectsByType(type: FEDERATION) { - id - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projectsByType", - "Query.projectsByType.type", - "Query.projectsByType.type!", - "Project.id", - "ProjectType.FEDERATION", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn arguments() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($limit: Int!, $type: ProjectType!) { - projects(filter: { pagination: { limit: $limit }, type: $type }) { - id - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Query.projects.filter!", - "Project.id", - "Int", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - "ProjectType.SINGLE", - "FilterInput.pagination", - "FilterInput.pagination!", - "FilterInput.type", - "PaginationInput.limit", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn skips_argument_directives() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($limit: Int!, $type: ProjectType!, $includeName: Boolean!) { - projects(filter: { pagination: { limit: $limit }, type: $type }) { - id - ...NestedFragment - } - } - - fragment NestedFragment on Project { - ...IncludeNameFragment @include(if: $includeName) - } - - fragment IncludeNameFragment on Project { - name - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Query.projects.filter!", - "Project.id", - "Project.name", - "Int", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - "ProjectType.SINGLE", - "Boolean", - "FilterInput.pagination", - "FilterInput.pagination!", - "FilterInput.type", - "PaginationInput.limit", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn used_only_input_fields() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($limit: Int!, $type: ProjectType!) { - projects(filter: { - pagination: { limit: $limit }, - type: $type - }) { - id - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Query.projects.filter!", - "Project.id", - "Int", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - "ProjectType.SINGLE", - "FilterInput.pagination", - "FilterInput.pagination!", - "FilterInput.type", - "PaginationInput.limit", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn input_object_mixed() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($pagination: PaginationInput!, $type: ProjectType!) { - projects(filter: { pagination: $pagination, type: $type }) { - id - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Query.projects.filter!", - "Project.id", - "PaginationInput.limit", - "Int", - "PaginationInput.offset", - "ProjectType.FEDERATION", - "ProjectType.STITCHING", - "ProjectType.SINGLE", - "FilterInput.pagination", - "FilterInput.type", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn custom_scalar_as_argument_inlined() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects { - projectsByMetadata(metadata: { key: { value: \"value\" } }) { - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projectsByMetadata", - "Query.projectsByMetadata.metadata", - "Query.projectsByMetadata.metadata!", - "Project.name", - "JSON", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn custom_scalar_as_argument_variable() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($metadata: JSON) { - projectsByMetadata(metadata: $metadata) { - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projectsByMetadata", - "Query.projectsByMetadata.metadata", - "Project.name", - "JSON", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn custom_scalar_as_argument_variable_with_default() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($metadata: JSON = { key: { value: \"value\" } }) { - projectsByMetadata(metadata: $metadata) { - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projectsByMetadata", - "Query.projectsByMetadata.metadata", - "Query.projectsByMetadata.metadata!", - "Project.name", - "JSON", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn custom_scalar_as_input_field_inlined() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects { - projects(filter: { metadata: { key: \"value\" } }) { - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Query.projects.filter!", - "FilterInput.metadata", - "FilterInput.metadata!", - "Project.name", - "JSON", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn custom_scalar_as_input_field_variable() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($metadata: JSON) { - projects(filter: { metadata: $metadata }) { - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Query.projects.filter!", - "FilterInput.metadata", - "Project.name", - "JSON", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn custom_scalar_as_input_field_variable_with_default() { - let schema = parse_schema::(SCHEMA_SDL).unwrap(); - let document = parse_query::( - " - query getProjects($metadata: JSON = { key: { value: \"value\" } }) { - projects(filter: { metadata: $metadata }) { - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.projects", - "Query.projects.filter", - "Query.projects.filter!", - "FilterInput.metadata", - "FilterInput.metadata!", - "Project.name", - "JSON", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn primitive_field_with_arg_schema_coor() { - let schema = parse_schema::( - "type Query { - hello(message: String): String - }", - ) - .unwrap(); - let document = parse_query::( - " - query { - hello(message: \"world\") - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "Query.hello", - "Query.hello.message!", - "Query.hello.message", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn unused_variable_as_nullable_argument() { - let schema = parse_schema::( - " - type Query { - random(a: String): String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query Foo($a: String) { - random(a: $a) - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec!["Query.random", "Query.random.a", "String"] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn unused_nullable_input_field() { - let schema = parse_schema::( - " - type Query { - random(a: A): String - } - input A { - b: B - } - input B { - c: C - } - input C { - d: String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query Foo { - random(a: { b: null }) - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "Query.random", - "Query.random.a", - "Query.random.a!", - "A.b", - "B.c", - "C.d", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn required_variable_as_input_field() { - let schema = parse_schema::( - " - type Query { - random(a: A): String - } - input A { - b: String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query Foo($b:String! = \"b\") { - random(a: { b: $b }) - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "Query.random", - "Query.random.a", - "Query.random.a!", - "A.b", - "A.b!", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn undefined_variable_as_input_field() { - let schema = parse_schema::( - " - type Query { - random(a: A): String - } - input A { - b: String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query Foo($b: String!) { - random(a: { b: $b }) - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "Query.random", - "Query.random.a", - "Query.random.a!", - "A.b", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn deeply_nested_variables() { - let schema = parse_schema::( - " - type Query { - random(a: A): String - } - input A { - b: B - } - input B { - c: C - } - input C { - d: String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query Random($a: A = { b: { c: { d: \"D\" } } }) { - random(a: $a) - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "Query.random", - "Query.random.a", - "Query.random.a!", - "A.b", - "A.b!", - "B.c", - "B.c!", - "C.d", - "C.d!", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn aliased_field() { - let schema = parse_schema::( - " - type Query { - random(a: String): String - } - input C { - d: String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query Random($a: String= \"B\" ) { - foo: random(a: $a ) - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "Query.random", - "Query.random.a", - "Query.random.a!", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn multiple_fields_with_mixed_nullability() { - let schema = parse_schema::( - " - type Query { - random(a: String): String - } - input C { - d: String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query Random($a: String = null) { - nullable: random(a: $a) - nonnullable: random(a: \"B\") - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "Query.random", - "Query.random.a", - "Query.random.a!", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn nonnull_and_default_arguments() { - let schema = parse_schema::( - " - type Query { - user(id: ID!, name: String): User - } - - type User { - id: ID! - name: String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query($id: ID! = \"123\") { - user(id: $id) { name } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "User.name", - "Query.user", - "ID", - "Query.user.id!", - "Query.user.id", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn default_nullable_arguments() { - let schema = parse_schema::( - " - type Query { - user(id: ID!, name: String): User - } - - type User { - id: ID! - name: String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query($name: String = \"John\") { - user(id: \"fixed\", name: $name) { id } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "User.id", - "Query.user", - "ID", - "Query.user.id!", - "Query.user.id", - "Query.user.name!", - "Query.user.name", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn non_null_no_default_arguments() { - let schema = parse_schema::( - " - type Query { - user(id: ID!, name: String): User - } - - type User { - id: ID! - name: String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query($id: ID!) { - user(id: $id) { name } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec!["User.name", "Query.user", "ID", "Query.user.id"] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn fixed_arguments() { - let schema = parse_schema::( - " - type Query { - user(id: ID!, name: String): User - } - - type User { - id: ID! - name: String - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query($name: String) { - user(id: \"fixed\", name: $name) { id } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "User.id", - "Query.user", - "ID", - "Query.user.id!", - "Query.user.id", - "Query.user.name", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn recursive_fragments() { - let schema = parse_schema::( - " - type Query { - user(id: ID!): User - } - type User { - id: ID! - friends: [User!]! - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query UserQuery($id: ID!) { - user(id: $id) { - ...UserFragment - } - } - fragment UserFragment on User { - id - friends { - ...UserFragment - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - - let expected = vec![ - "Query.user", - "Query.user.id", - "User.id", - "User.friends", - "ID", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn recursive_input_types() { - let schema = parse_schema::( - " - type Query { - node(id: ID!): Node - } - - type Mutation { - createNode(input: NodeInput!): Node - } - input NodeInput { - name: String! - parent: NodeInput - } - type Node { - id: ID! - name: String! - parent: Node - } - ", - ) - .unwrap(); - let document = parse_query::( - " - mutation CreateNode($input: NodeInput!) { - createNode(input: $input) { - id - name - } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "Mutation.createNode", - "Mutation.createNode.input", - "Node.id", - "Node.name", - "NodeInput.name", - "NodeInput.parent", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } - - #[test] - fn recursive_null_input() { - let schema = parse_schema::( - " - type Query { - someField(input: RecursiveInput!): SomeType - } - input RecursiveInput { - field: String - nested: RecursiveInput - } - type SomeType { - id: ID! - } - ", - ) - .unwrap(); - let document = parse_query::( - " - query MyQuery { - someField(input: { nested: null }) { id } - } - ", - ) - .unwrap(); - - let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); - let expected = vec![ - "Query.someField", - "Query.someField.input", - "Query.someField.input!", - "SomeType.id", - "RecursiveInput.field", - "RecursiveInput.nested", - "String", - ] - .into_iter() - .map(|s| s.to_string()) - .collect::>(); - - let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); - let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); - - assert_eq!(extra.len(), 0, "Extra: {:?}", extra); - assert_eq!(missing.len(), 0, "Missing: {:?}", missing); - } -} diff --git a/packages/libraries/sdk-rs/src/circuit_breaker.rs b/packages/libraries/sdk-rs/src/circuit_breaker.rs deleted file mode 100644 index 0dbd4529c..000000000 --- a/packages/libraries/sdk-rs/src/circuit_breaker.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::time::Duration; - -use recloser::{AsyncRecloser, Recloser}; - -#[derive(Clone)] -pub struct CircuitBreakerBuilder { - error_threshold: f32, - volume_threshold: usize, - reset_timeout: Duration, -} - -impl Default for CircuitBreakerBuilder { - fn default() -> Self { - Self { - error_threshold: 0.5, - volume_threshold: 5, - reset_timeout: Duration::from_secs(30), - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum CircuitBreakerError { - #[error("Invalid error threshold: {0}. It must be between 0.0 and 1.0")] - InvalidErrorThreshold(f32), -} - -impl CircuitBreakerBuilder { - /// Percentage after what the circuit breaker should kick in. - /// Default: .5 - pub fn error_threshold(mut self, percentage: f32) -> Self { - self.error_threshold = percentage; - self - } - /// Count of requests before starting evaluating. - /// Default: 5 - pub fn volume_threshold(mut self, threshold: usize) -> Self { - self.volume_threshold = threshold; - self - } - /// After what time the circuit breaker is attempting to retry sending requests in milliseconds. - /// Default: 30s - pub fn reset_timeout(mut self, timeout: Duration) -> Self { - self.reset_timeout = timeout; - self - } - - pub fn build_async(self) -> Result { - let recloser = self.build_sync()?; - Ok(AsyncRecloser::from(recloser)) - } - pub fn build_sync(self) -> Result { - let error_threshold = if self.error_threshold < 0.0 || self.error_threshold > 1.0 { - return Err(CircuitBreakerError::InvalidErrorThreshold( - self.error_threshold, - )); - } else { - self.error_threshold - }; - let recloser = Recloser::custom() - .error_rate(error_threshold) - .closed_len(self.volume_threshold) - .open_wait(self.reset_timeout) - .build(); - Ok(recloser) - } -} diff --git a/packages/libraries/sdk-rs/src/lib.rs b/packages/libraries/sdk-rs/src/lib.rs deleted file mode 100644 index 0201c9cc2..000000000 --- a/packages/libraries/sdk-rs/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod agent; -pub mod circuit_breaker; -pub mod persisted_documents; -pub mod supergraph_fetcher; diff --git a/packages/libraries/sdk-rs/src/persisted_documents.rs b/packages/libraries/sdk-rs/src/persisted_documents.rs deleted file mode 100644 index 4a5aab95c..000000000 --- a/packages/libraries/sdk-rs/src/persisted_documents.rs +++ /dev/null @@ -1,326 +0,0 @@ -use std::time::Duration; - -use crate::agent::usage_agent::non_empty_string; -use crate::circuit_breaker::CircuitBreakerBuilder; -use moka::future::Cache; -use recloser::AsyncRecloser; -use reqwest::header::HeaderMap; -use reqwest::header::HeaderValue; -use reqwest_middleware::ClientBuilder; -use reqwest_middleware::ClientWithMiddleware; -use reqwest_retry::RetryTransientMiddleware; -use retry_policies::policies::ExponentialBackoff; -use tracing::{debug, info, warn}; - -#[derive(Debug)] -pub struct PersistedDocumentsManager { - client: ClientWithMiddleware, - cache: Cache, - endpoints_with_circuit_breakers: Vec<(String, AsyncRecloser)>, -} - -#[derive(Debug, thiserror::Error)] -pub enum PersistedDocumentsError { - #[error("Failed to read body: {0}")] - FailedToReadBody(String), - #[error("Failed to parse body: {0}")] - FailedToParseBody(serde_json::Error), - #[error("Persisted document not found.")] - DocumentNotFound, - #[error("Failed to locate the persisted document key in request.")] - KeyNotFound, - #[error("Failed to validate persisted document")] - FailedToFetchFromCDN(reqwest_middleware::Error), - #[error("Failed to read CDN response body")] - FailedToReadCDNResponse(reqwest::Error), - #[error("No persisted document provided, or document id cannot be resolved.")] - PersistedDocumentRequired, - #[error("Missing required configuration option: {0}")] - MissingConfigurationOption(String), - #[error("Invalid CDN key {0}")] - InvalidCDNKey(String), - #[error("Failed to create HTTP client: {0}")] - HTTPClientCreationError(reqwest::Error), - #[error("unable to create circuit breaker: {0}")] - CircuitBreakerCreationError(#[from] crate::circuit_breaker::CircuitBreakerError), - #[error("rejected by the circuit breaker")] - CircuitBreakerRejected, - #[error("unknown error")] - Unknown, -} - -impl PersistedDocumentsError { - pub fn message(&self) -> String { - self.to_string() - } - - pub fn code(&self) -> String { - match self { - PersistedDocumentsError::FailedToReadBody(_) => "FAILED_TO_READ_BODY".into(), - PersistedDocumentsError::FailedToParseBody(_) => "FAILED_TO_PARSE_BODY".into(), - PersistedDocumentsError::DocumentNotFound => "PERSISTED_DOCUMENT_NOT_FOUND".into(), - PersistedDocumentsError::KeyNotFound => "PERSISTED_DOCUMENT_KEY_NOT_FOUND".into(), - PersistedDocumentsError::FailedToFetchFromCDN(_) => "FAILED_TO_FETCH_FROM_CDN".into(), - PersistedDocumentsError::FailedToReadCDNResponse(_) => { - "FAILED_TO_READ_CDN_RESPONSE".into() - } - PersistedDocumentsError::PersistedDocumentRequired => { - "PERSISTED_DOCUMENT_REQUIRED".into() - } - PersistedDocumentsError::MissingConfigurationOption(_) => { - "MISSING_CONFIGURATION_OPTION".into() - } - PersistedDocumentsError::InvalidCDNKey(_) => "INVALID_CDN_KEY".into(), - PersistedDocumentsError::HTTPClientCreationError(_) => { - "HTTP_CLIENT_CREATION_ERROR".into() - } - PersistedDocumentsError::CircuitBreakerCreationError(_) => { - "CIRCUIT_BREAKER_CREATION_ERROR".into() - } - PersistedDocumentsError::CircuitBreakerRejected => "CIRCUIT_BREAKER_REJECTED".into(), - PersistedDocumentsError::Unknown => "UNKNOWN_ERROR".into(), - } - } -} - -impl PersistedDocumentsManager { - pub fn builder() -> PersistedDocumentsManagerBuilder { - PersistedDocumentsManagerBuilder::default() - } - async fn resolve_from_endpoint( - &self, - endpoint: &str, - document_id: &str, - circuit_breaker: &AsyncRecloser, - ) -> Result { - let cdn_document_id = str::replace(document_id, "~", "/"); - let cdn_artifact_url = format!("{}/apps/{}", endpoint, cdn_document_id); - info!( - "Fetching document {} from CDN: {}", - document_id, cdn_artifact_url - ); - let response_fut = self.client.get(cdn_artifact_url).send(); - - let response = circuit_breaker - .call(response_fut) - .await - .map_err(|e| match e { - recloser::Error::Inner(e) => PersistedDocumentsError::FailedToFetchFromCDN(e), - recloser::Error::Rejected => PersistedDocumentsError::CircuitBreakerRejected, - })?; - - if response.status().is_success() { - let document = response - .text() - .await - .map_err(PersistedDocumentsError::FailedToReadCDNResponse)?; - debug!( - "Document fetched from CDN: {}, storing in local cache", - document - ); - self.cache - .insert(document_id.into(), document.clone()) - .await; - - return Ok(document); - } - - warn!( - "Document fetch from CDN failed: HTTP {}, Body: {:?}", - response.status(), - response - .text() - .await - .unwrap_or_else(|_| "Unavailable".to_string()) - ); - - Err(PersistedDocumentsError::DocumentNotFound) - } - /// Resolves the document from the cache, or from the CDN - pub async fn resolve_document( - &self, - document_id: &str, - ) -> Result { - let cached_record = self.cache.get(document_id).await; - - match cached_record { - Some(document) => { - debug!("Document {} found in cache: {}", document_id, document); - - Ok(document) - } - None => { - debug!( - "Document {} not found in cache. Fetching from CDN", - document_id - ); - let mut last_error: Option = None; - for (endpoint, circuit_breaker) in &self.endpoints_with_circuit_breakers { - let result = self - .resolve_from_endpoint(endpoint, document_id, circuit_breaker) - .await; - match result { - Ok(document) => return Ok(document), - Err(e) => { - last_error = Some(e); - } - } - } - match last_error { - Some(e) => Err(e), - None => Err(PersistedDocumentsError::Unknown), - } - } - } - } -} - -pub struct PersistedDocumentsManagerBuilder { - key: Option, - endpoints: Vec, - accept_invalid_certs: bool, - connect_timeout: Duration, - request_timeout: Duration, - retry_policy: ExponentialBackoff, - cache_size: u64, - user_agent: Option, - circuit_breaker: CircuitBreakerBuilder, -} - -impl Default for PersistedDocumentsManagerBuilder { - fn default() -> Self { - Self { - key: None, - endpoints: vec![], - accept_invalid_certs: false, - connect_timeout: Duration::from_secs(5), - request_timeout: Duration::from_secs(15), - retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), - cache_size: 10_000, - user_agent: None, - circuit_breaker: CircuitBreakerBuilder::default(), - } - } -} - -impl PersistedDocumentsManagerBuilder { - /// The CDN Access Token with from the Hive Console target. - pub fn key(mut self, key: String) -> Self { - self.key = non_empty_string(Some(key)); - self - } - - /// The CDN endpoint from Hive Console target. - pub fn add_endpoint(mut self, endpoint: String) -> Self { - if let Some(endpoint) = non_empty_string(Some(endpoint)) { - self.endpoints.push(endpoint); - } - self - } - - /// Accept invalid SSL certificates - /// default: false - pub fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self { - self.accept_invalid_certs = accept_invalid_certs; - self - } - - /// Connection timeout for the Hive Console CDN requests. - /// Default: 5 seconds - pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self { - self.connect_timeout = connect_timeout; - self - } - - /// Request timeout for the Hive Console CDN requests. - /// Default: 15 seconds - pub fn request_timeout(mut self, request_timeout: Duration) -> Self { - self.request_timeout = request_timeout; - self - } - - /// Retry policy for fetching persisted documents - /// Default: ExponentialBackoff with max 3 retries - pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { - self.retry_policy = retry_policy; - self - } - - /// Maximum number of retries for fetching persisted documents - /// Default: ExponentialBackoff with max 3 retries - pub fn max_retries(mut self, max_retries: u32) -> Self { - self.retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); - self - } - - /// Size of the in-memory cache for persisted documents - /// Default: 10,000 entries - pub fn cache_size(mut self, cache_size: u64) -> Self { - self.cache_size = cache_size; - self - } - - /// User-Agent header to be sent with each request - pub fn user_agent(mut self, user_agent: String) -> Self { - self.user_agent = non_empty_string(Some(user_agent)); - self - } - - pub fn build(self) -> Result { - let mut default_headers = HeaderMap::new(); - let key = match self.key { - Some(key) => key, - None => { - return Err(PersistedDocumentsError::MissingConfigurationOption( - "key".to_string(), - )); - } - }; - default_headers.insert( - "X-Hive-CDN-Key", - HeaderValue::from_str(&key) - .map_err(|e| PersistedDocumentsError::InvalidCDNKey(e.to_string()))?, - ); - let mut reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(self.accept_invalid_certs) - .connect_timeout(self.connect_timeout) - .timeout(self.request_timeout) - .default_headers(default_headers); - - if let Some(user_agent) = self.user_agent { - reqwest_agent = reqwest_agent.user_agent(user_agent); - } - - let reqwest_agent = reqwest_agent - .build() - .map_err(PersistedDocumentsError::HTTPClientCreationError)?; - let client = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) - .build(); - - let cache = Cache::::new(self.cache_size); - - if self.endpoints.is_empty() { - return Err(PersistedDocumentsError::MissingConfigurationOption( - "endpoints".to_string(), - )); - } - - Ok(PersistedDocumentsManager { - client, - cache, - endpoints_with_circuit_breakers: self - .endpoints - .into_iter() - .map(move |endpoint| { - let circuit_breaker = self - .circuit_breaker - .clone() - .build_async() - .map_err(PersistedDocumentsError::CircuitBreakerCreationError)?; - Ok((endpoint, circuit_breaker)) - }) - .collect::, PersistedDocumentsError>>()?, - }) - } -} diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs deleted file mode 100644 index b0bb1eddb..000000000 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher/async_.rs +++ /dev/null @@ -1,141 +0,0 @@ -use futures_util::TryFutureExt; -use recloser::AsyncRecloser; -use reqwest::header::{HeaderValue, IF_NONE_MATCH}; -use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; -use reqwest_retry::RetryTransientMiddleware; -use tokio::sync::RwLock; - -use crate::supergraph_fetcher::{ - builder::SupergraphFetcherBuilder, SupergraphFetcher, SupergraphFetcherError, -}; - -#[derive(Debug)] -pub struct SupergraphFetcherAsyncState { - endpoints_with_circuit_breakers: Vec<(String, AsyncRecloser)>, - reqwest_client: ClientWithMiddleware, -} - -impl SupergraphFetcher { - pub async fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { - let mut last_error: Option = None; - let mut last_resp = None; - for (endpoint, circuit_breaker) in &self.state.endpoints_with_circuit_breakers { - let mut req = self.state.reqwest_client.get(endpoint); - let etag = self.get_latest_etag().await; - if let Some(etag) = etag { - req = req.header(IF_NONE_MATCH, etag); - } - let resp_fut = async { - let mut resp = req.send().await.map_err(SupergraphFetcherError::Network); - // Server errors (5xx) are considered errors - if let Ok(ok_res) = resp { - resp = if ok_res.status().is_server_error() { - return Err(SupergraphFetcherError::Network( - reqwest_middleware::Error::Middleware(anyhow::anyhow!( - "Server error: {}", - ok_res.status() - )), - )); - } else { - Ok(ok_res) - } - } - resp - }; - let resp = circuit_breaker - .call(resp_fut) - // Map recloser errors to SupergraphFetcherError - .map_err(|e| match e { - recloser::Error::Inner(e) => e, - recloser::Error::Rejected => SupergraphFetcherError::RejectedByCircuitBreaker, - }) - .await; - match resp { - Err(err) => { - last_error = Some(err); - continue; - } - Ok(resp) => { - last_resp = Some(resp); - break; - } - } - } - - if let Some(last_resp) = last_resp { - let etag = last_resp.headers().get("etag"); - self.update_latest_etag(etag).await; - let text = last_resp - .text() - .await - .map_err(SupergraphFetcherError::ResponseParse)?; - Ok(Some(text)) - } else if let Some(error) = last_error { - Err(error) - } else { - Ok(None) - } - } - async fn get_latest_etag(&self) -> Option { - let guard = self.etag.read().await; - - guard.clone() - } - async fn update_latest_etag(&self, etag: Option<&HeaderValue>) -> () { - let mut guard = self.etag.write().await; - - if let Some(etag_value) = etag { - *guard = Some(etag_value.clone()); - } else { - *guard = None; - } - } -} - -impl SupergraphFetcherBuilder { - /// Builds an asynchronous SupergraphFetcher - pub fn build_async( - self, - ) -> Result, SupergraphFetcherError> { - self.validate_endpoints()?; - - let headers = self.prepare_headers()?; - - let mut reqwest_agent = reqwest::Client::builder() - .danger_accept_invalid_certs(self.accept_invalid_certs) - .connect_timeout(self.connect_timeout) - .timeout(self.request_timeout) - .default_headers(headers); - - if let Some(user_agent) = self.user_agent { - reqwest_agent = reqwest_agent.user_agent(user_agent); - } - - let reqwest_agent = reqwest_agent - .build() - .map_err(SupergraphFetcherError::HTTPClientCreation)?; - let reqwest_client = ClientBuilder::new(reqwest_agent) - .with(RetryTransientMiddleware::new_with_policy(self.retry_policy)) - .build(); - - Ok(SupergraphFetcher { - state: SupergraphFetcherAsyncState { - reqwest_client, - endpoints_with_circuit_breakers: self - .endpoints - .into_iter() - .map(|endpoint| { - let circuit_breaker = self - .circuit_breaker - .clone() - .unwrap_or_default() - .build_async() - .map_err(SupergraphFetcherError::CircuitBreakerCreation); - circuit_breaker.map(|cb| (endpoint, cb)) - }) - .collect::, _>>()?, - }, - etag: RwLock::new(None), - }) - } -} diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs deleted file mode 100644 index adddc0112..000000000 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher/builder.rs +++ /dev/null @@ -1,135 +0,0 @@ -use std::time::Duration; - -use reqwest::header::{HeaderMap, HeaderValue}; -use retry_policies::policies::ExponentialBackoff; - -use crate::{ - agent::usage_agent::non_empty_string, circuit_breaker::CircuitBreakerBuilder, - supergraph_fetcher::SupergraphFetcherError, -}; - -pub struct SupergraphFetcherBuilder { - pub(crate) endpoints: Vec, - pub(crate) key: Option, - pub(crate) user_agent: Option, - pub(crate) connect_timeout: Duration, - pub(crate) request_timeout: Duration, - pub(crate) accept_invalid_certs: bool, - pub(crate) retry_policy: ExponentialBackoff, - pub(crate) circuit_breaker: Option, -} - -impl Default for SupergraphFetcherBuilder { - fn default() -> Self { - Self { - endpoints: vec![], - key: None, - user_agent: None, - connect_timeout: Duration::from_secs(5), - request_timeout: Duration::from_secs(60), - accept_invalid_certs: false, - retry_policy: ExponentialBackoff::builder().build_with_max_retries(3), - circuit_breaker: None, - } - } -} - -impl SupergraphFetcherBuilder { - pub fn new() -> Self { - Self::default() - } - - /// The CDN endpoint from Hive Console target. - pub fn add_endpoint(mut self, endpoint: String) -> Self { - if let Some(mut endpoint) = non_empty_string(Some(endpoint)) { - if !endpoint.ends_with("/supergraph") { - if endpoint.ends_with("/") { - endpoint.push_str("supergraph"); - } else { - endpoint.push_str("/supergraph"); - } - } - self.endpoints.push(endpoint); - } - self - } - - /// The CDN Access Token with from the Hive Console target. - pub fn key(mut self, key: String) -> Self { - self.key = Some(key); - self - } - - /// User-Agent header to be sent with each request - pub fn user_agent(mut self, user_agent: String) -> Self { - self.user_agent = Some(user_agent); - self - } - - /// Connection timeout for the Hive Console CDN requests. - /// Default: 5 seconds - pub fn connect_timeout(mut self, timeout: Duration) -> Self { - self.connect_timeout = timeout; - self - } - - /// Request timeout for the Hive Console CDN requests. - /// Default: 60 seconds - pub fn request_timeout(mut self, timeout: Duration) -> Self { - self.request_timeout = timeout; - self - } - - pub fn accept_invalid_certs(mut self, accept: bool) -> Self { - self.accept_invalid_certs = accept; - self - } - - /// Policy for retrying failed requests. - /// - /// By default, an exponential backoff retry policy is used, with 10 attempts. - pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self { - self.retry_policy = retry_policy; - self - } - - /// Maximum number of retries for failed requests. - /// - /// By default, an exponential backoff retry policy is used, with 10 attempts. - pub fn max_retries(mut self, max_retries: u32) -> Self { - self.retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries); - self - } - - pub fn circuit_breaker(&mut self, builder: CircuitBreakerBuilder) -> &mut Self { - self.circuit_breaker = Some(builder); - self - } - - pub(crate) fn validate_endpoints(&self) -> Result<(), SupergraphFetcherError> { - if self.endpoints.is_empty() { - return Err(SupergraphFetcherError::MissingConfigurationOption( - "endpoint".to_string(), - )); - } - Ok(()) - } - - pub(crate) fn prepare_headers(&self) -> Result { - let key = match &self.key { - Some(key) => key, - None => { - return Err(SupergraphFetcherError::MissingConfigurationOption( - "key".to_string(), - )) - } - }; - let mut headers = HeaderMap::new(); - let mut cdn_key_header = - HeaderValue::from_str(key).map_err(SupergraphFetcherError::InvalidKey)?; - cdn_key_header.set_sensitive(true); - headers.insert("X-Hive-CDN-Key", cdn_key_header); - - Ok(headers) - } -} diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs deleted file mode 100644 index 2441ea371..000000000 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher/mod.rs +++ /dev/null @@ -1,51 +0,0 @@ -use tokio::sync::RwLock; -use tokio::sync::TryLockError; - -use crate::circuit_breaker::CircuitBreakerError; -use crate::supergraph_fetcher::async_::SupergraphFetcherAsyncState; -use reqwest::header::HeaderValue; -use reqwest::header::InvalidHeaderValue; - -pub mod async_; -pub mod builder; -pub mod sync; - -#[derive(Debug)] -pub struct SupergraphFetcher { - state: State, - etag: RwLock>, -} - -// Doesn't matter which one we implement this for, both have the same builder -impl SupergraphFetcher { - pub fn builder() -> builder::SupergraphFetcherBuilder { - builder::SupergraphFetcherBuilder::default() - } -} - -pub enum LockErrorType { - Read, - Write, -} - -#[derive(Debug, thiserror::Error)] -pub enum SupergraphFetcherError { - #[error("Creating HTTP Client failed: {0}")] - HTTPClientCreation(reqwest::Error), - #[error("Network error: {0}")] - Network(reqwest_middleware::Error), - #[error("Parsing response failed: {0}")] - ResponseParse(reqwest::Error), - #[error("Reading the etag record failed: {0:?}")] - ETagRead(TryLockError), - #[error("Updating the etag record failed: {0:?}")] - ETagWrite(TryLockError), - #[error("Invalid CDN key: {0}")] - InvalidKey(InvalidHeaderValue), - #[error("Missing configuration option: {0}")] - MissingConfigurationOption(String), - #[error("Request rejected by circuit breaker")] - RejectedByCircuitBreaker, - #[error("Creating circuit breaker failed: {0}")] - CircuitBreakerCreation(CircuitBreakerError), -} diff --git a/packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs b/packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs deleted file mode 100644 index f85aa58fe..000000000 --- a/packages/libraries/sdk-rs/src/supergraph_fetcher/sync.rs +++ /dev/null @@ -1,191 +0,0 @@ -use std::time::SystemTime; - -use recloser::Recloser; -use reqwest::header::{HeaderValue, IF_NONE_MATCH}; -use reqwest_retry::{RetryDecision, RetryPolicy}; -use retry_policies::policies::ExponentialBackoff; -use tokio::sync::RwLock; - -use crate::supergraph_fetcher::{ - builder::SupergraphFetcherBuilder, SupergraphFetcher, SupergraphFetcherError, -}; - -#[derive(Debug)] -pub struct SupergraphFetcherSyncState { - endpoints_with_circuit_breakers: Vec<(String, Recloser)>, - reqwest_client: reqwest::blocking::Client, - retry_policy: ExponentialBackoff, -} - -impl SupergraphFetcher { - pub fn fetch_supergraph(&self) -> Result, SupergraphFetcherError> { - let mut last_error: Option = None; - let mut last_resp = None; - for (endpoint, circuit_breaker) in &self.state.endpoints_with_circuit_breakers { - let resp = { - circuit_breaker - .call(|| { - let request_start_time = SystemTime::now(); - // Implementing retry logic for sync client - let mut n_past_retries = 0; - loop { - let mut req = self.state.reqwest_client.get(endpoint); - let etag = self.get_latest_etag()?; - if let Some(etag) = etag { - req = req.header(IF_NONE_MATCH, etag); - } - let mut response = req.send().map_err(|err| { - SupergraphFetcherError::Network(reqwest_middleware::Error::Reqwest( - err, - )) - }); - - // Server errors (5xx) are considered retryable - if let Ok(ok_res) = response { - response = if ok_res.status().is_server_error() { - Err(SupergraphFetcherError::Network( - reqwest_middleware::Error::Middleware(anyhow::anyhow!( - "Server error: {}", - ok_res.status() - )), - )) - } else { - Ok(ok_res) - } - } - - match response { - Ok(resp) => break Ok(resp), - Err(e) => { - match self - .state - .retry_policy - .should_retry(request_start_time, n_past_retries) - { - RetryDecision::DoNotRetry => { - return Err(e); - } - RetryDecision::Retry { execute_after } => { - n_past_retries += 1; - match execute_after.elapsed() { - Ok(duration) => { - std::thread::sleep(duration); - } - Err(err) => { - tracing::error!( - "Error determining sleep duration for retry: {}", - err - ); - // If elapsed time cannot be determined, do not wait - return Err(e); - } - } - } - } - } - } - } - }) - // Map recloser errors to SupergraphFetcherError - .map_err(|e| match e { - recloser::Error::Inner(e) => e, - recloser::Error::Rejected => { - SupergraphFetcherError::RejectedByCircuitBreaker - } - }) - }; - match resp { - Err(e) => { - last_error = Some(e); - continue; - } - Ok(resp) => { - last_resp = Some(resp); - break; - } - } - } - - if let Some(last_resp) = last_resp { - if last_resp.status().as_u16() == 304 { - return Ok(None); - } - self.update_latest_etag(last_resp.headers().get("etag"))?; - let text = last_resp - .text() - .map_err(SupergraphFetcherError::ResponseParse)?; - Ok(Some(text)) - } else if let Some(error) = last_error { - Err(error) - } else { - Ok(None) - } - } - fn get_latest_etag(&self) -> Result, SupergraphFetcherError> { - let guard = self - .etag - .try_read() - .map_err(SupergraphFetcherError::ETagRead)?; - - Ok(guard.clone()) - } - fn update_latest_etag(&self, etag: Option<&HeaderValue>) -> Result<(), SupergraphFetcherError> { - let mut guard = self - .etag - .try_write() - .map_err(SupergraphFetcherError::ETagWrite)?; - - if let Some(etag_value) = etag { - *guard = Some(etag_value.clone()); - } else { - *guard = None; - } - - Ok(()) - } -} - -impl SupergraphFetcherBuilder { - /// Builds a synchronous SupergraphFetcher - pub fn build_sync( - self, - ) -> Result, SupergraphFetcherError> { - self.validate_endpoints()?; - let headers = self.prepare_headers()?; - - let mut reqwest_client = reqwest::blocking::Client::builder() - .danger_accept_invalid_certs(self.accept_invalid_certs) - .connect_timeout(self.connect_timeout) - .timeout(self.request_timeout) - .default_headers(headers); - - if let Some(user_agent) = &self.user_agent { - reqwest_client = reqwest_client.user_agent(user_agent); - } - - let reqwest_client = reqwest_client - .build() - .map_err(SupergraphFetcherError::HTTPClientCreation)?; - let fetcher = SupergraphFetcher { - state: SupergraphFetcherSyncState { - reqwest_client, - retry_policy: self.retry_policy, - endpoints_with_circuit_breakers: self - .endpoints - .into_iter() - .map(|endpoint| { - let circuit_breaker = self - .circuit_breaker - .clone() - .unwrap_or_default() - .build_sync() - .map_err(SupergraphFetcherError::CircuitBreakerCreation); - circuit_breaker.map(|cb| (endpoint, cb)) - }) - .collect::, _>>()?, - }, - etag: RwLock::new(None), - }; - Ok(fetcher) - } -} diff --git a/packages/libraries/sdk-rs/sync-cargo-file.sh b/packages/libraries/sdk-rs/sync-cargo-file.sh deleted file mode 100755 index af79fb189..000000000 --- a/packages/libraries/sdk-rs/sync-cargo-file.sh +++ /dev/null @@ -1,14 +0,0 @@ -#/bin/bash - -# The following script syncs the "version" field in package.json to the "package.version" field in Cargo.toml -# This main versioning flow is managed by Changeset. -# This file is executed during "changeset version" (when the version is bumped and release PR is created) -# to sync the version in Cargo.toml - -# References: -# .github/workflows/publish-rust.yaml - The GitHub action that runs this script (after "changeset version") -# .github/workflows/main-rust.yaml - The GitHub action that does the actual publishing, if a changeset is declared to this package/crate - -npm_version=$(node -p "require('./package.json').version") -cargo install set-cargo-version -set-cargo-version ./Cargo.toml $npm_version diff --git a/packages/libraries/sdk-rs/usage-report-v2.schema.json b/packages/libraries/sdk-rs/usage-report-v2.schema.json deleted file mode 120000 index 60413ce64..000000000 --- a/packages/libraries/sdk-rs/usage-report-v2.schema.json +++ /dev/null @@ -1 +0,0 @@ -../../services/usage/usage-report-v2.schema.json \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a556d487..6cdec9d2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -618,13 +618,7 @@ importers: version: 16.9.0 publishDirectory: dist - packages/libraries/router: - dependencies: - hive-console-sdk-rs: - specifier: workspace:* - version: link:../sdk-rs - - packages/libraries/sdk-rs: {} + packages/libraries/router: {} packages/libraries/yoga: dependencies: