diff --git a/.changeset/rude-cats-lead.md b/.changeset/rude-cats-lead.md new file mode 100644 index 000000000..99786eca0 --- /dev/null +++ b/.changeset/rude-cats-lead.md @@ -0,0 +1,5 @@ +--- +'hive-console-sdk-rs': patch +--- + +Fix the bug where reports were not being sent correctly due to missing headers diff --git a/.github/workflows/publish-rust.yaml b/.github/workflows/publish-rust.yaml index 9d6f7de2e..3c7d70e69 100644 --- a/.github/workflows/publish-rust.yaml +++ b/.github/workflows/publish-rust.yaml @@ -32,9 +32,43 @@ jobs: echo 'rust_changed=true' >> $GITHUB_OUTPUT fi - publish-rust: + test-rust: needs: detect-changes if: needs.detect-changes.outputs.rust_changed == 'true' + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 2 + + - name: setup environment + uses: ./.github/actions/setup + with: + actor: test-rust + codegen: false + + - name: Install Protoc + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust + uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1 + with: + toolchain: '1.90.0' + default: true + override: true + + - name: Cache Rust + uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 + + - name: Run tests + run: cargo test + + publish-rust: + needs: [detect-changes, test-rust] + if: needs.detect-changes.outputs.rust_changed == 'true' strategy: fail-fast: false matrix: diff --git a/.github/workflows/release-alpha.yaml b/.github/workflows/release-alpha.yaml index 4bc970a60..ed3f80bca 100644 --- a/.github/workflows/release-alpha.yaml +++ b/.github/workflows/release-alpha.yaml @@ -138,3 +138,69 @@ jobs: cliVersion: ${{ needs.extract-cli-version.outputs.version }} publishLatest: false secrets: inherit + + cargo: + needs: [npm] + runs-on: ubuntu-22.04 + permissions: + pull-requests: write + actions: write + contents: write + steps: + - name: checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 2 + token: ${{ secrets.BOT_GITHUB_TOKEN }} + + - name: setup environment + uses: ./.github/actions/setup + with: + codegen: false # no need to run because release script will run it anyway + actor: alpha-rust + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: extract published plugin version + id: plugin-crate + if: + needs.npm.outputs.published && contains(needs.npm.outputs.publishedPackages, + '"hive-apollo-router-plugin"') + run: | + cargo install set-cargo-version + echo '${{needs.npm.outputs.publishedPackages}}' > published.json + + VERSION=`echo $(jq -r '.[] | select(.name | endswith("hive-apollo-router-plugin")).version' published.json)` + set-cargo-version ./packages/libraries/router/Cargo.toml $VERSION + + - name: extract published sdk version + id: sdk-crate + if: + needs.npm.outputs.published && contains(needs.npm.outputs.publishedPackages, + '"hive-console-sdk-rs"') + run: | + cargo install set-cargo-version + echo '${{needs.npm.outputs.publishedPackages}}' > published.json + + VERSION=`echo $(jq -r '.[] | select(.name | endswith("hive-console-sdk-rs")).version' published.json)` + set-cargo-version ./packages/libraries/sdk-rs/Cargo.toml $VERSION + + echo "crate_publish=true" >> $GITHUB_OUTPUT + + # Find and replace the version of "hive-console-sdk" dependency in router Cargo.toml + if [ -f ./packages/libraries/router/Cargo.toml ]; then + SDK_VERSION_LINE=$(grep -n 'hive-console-sdk' ./packages/libraries/router/Cargo.toml | head -n 1 | cut -d: -f1) + if [ ! -z "$SDK_VERSION_LINE" ]; then + sed -i "${SDK_VERSION_LINE}s/version = \".*\"/version = \"$VERSION\"/" ./packages/libraries/router/Cargo.toml + fi + fi + + - name: release to Crates.io + if: + steps.sdk-crate.outputs.crate_publish == 'true' || + steps.plugin-crate.outputs.crate_publish == 'true' + run: | + cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }} + cargo publish --allow-dirty --no-verify diff --git a/.github/workflows/release-stable.yaml b/.github/workflows/release-stable.yaml index bb612b860..9eb8f638f 100644 --- a/.github/workflows/release-stable.yaml +++ b/.github/workflows/release-stable.yaml @@ -112,19 +112,10 @@ jobs: VERSION: ${{ steps.cli.outputs.version }} run: pnpm oclif promote --no-xz --sha ${GITHUB_SHA:0:7} --version $VERSION - - name: extract published Crate version - id: rust-crate + - name: release to Crates.io if: steps.changesets.outputs.published && contains(steps.changesets.outputs.publishedPackages, '"hive-apollo-router-plugin"') - run: | - echo '${{steps.changesets.outputs.publishedPackages}}' > published.json - VERSION=`echo $(jq -r '.[] | select(.name | endswith("hive-apollo-router-plugin")).version' published.json)` - echo "crate_version=$VERSION" >> $GITHUB_OUTPUT - echo "crate_publish=true" >> $GITHUB_OUTPUT - - - name: release to Crates.io - if: steps.rust-crate.outputs.crate_publish == 'true' run: | cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }} cargo publish --allow-dirty --no-verify diff --git a/configs/cargo/Cargo.lock b/configs/cargo/Cargo.lock index 169907587..24fe2fb4e 100644 --- a/configs/cargo/Cargo.lock +++ b/configs/cargo/Cargo.lock @@ -1326,6 +1326,15 @@ 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" @@ -2625,7 +2634,7 @@ dependencies = [ "rand 0.9.2", "reqwest", "reqwest-middleware", - "reqwest-retry", + "reqwest-retry 0.7.0", "schemars 1.0.4", "serde", "serde_json", @@ -2646,10 +2655,11 @@ dependencies = [ "graphql-parser", "graphql-tools", "md5", + "mockito", "moka", "reqwest", "reqwest-middleware", - "reqwest-retry", + "reqwest-retry 0.8.0", "serde", "serde_json", "sha2", @@ -3590,6 +3600,31 @@ 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" @@ -4451,7 +4486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck 0.5.0", - "itertools 0.11.0", + "itertools 0.14.0", "log", "multimap 0.10.1", "once_cell", @@ -4471,7 +4506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.108", @@ -4846,13 +4881,34 @@ dependencies = [ "parking_lot 0.11.2", "reqwest", "reqwest-middleware", - "retry-policies", + "retry-policies 0.4.0", "thiserror 1.0.69", "tokio", "tracing", "wasm-timer", ] +[[package]] +name = "reqwest-retry" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105747e3a037fe5bf17458d794de91149e575b6183fc72c85623a44abb9683f5" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "getrandom 0.2.16", + "http 1.3.1", + "hyper 1.7.0", + "reqwest", + "reqwest-middleware", + "retry-policies 0.5.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "wasmtimer", +] + [[package]] name = "resolv-conf" version = "0.7.5" @@ -4868,6 +4924,15 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "retry-policies" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a4bd6027df676bcb752d3724db0ea3c0c5fc1dd0376fec51ac7dcaf9cc69be" +dependencies = [ + "rand 0.9.2", +] + [[package]] name = "rhai" version = "1.21.0" @@ -6365,13 +6430,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -6530,6 +6595,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot 0.12.5", + "pin-utils", + "slab", + "wasm-bindgen", +] + [[package]] name = "web-sys" version = "0.3.82" diff --git a/packages/libraries/router/src/usage.rs b/packages/libraries/router/src/usage.rs index 6c5a3a139..1964a6e04 100644 --- a/packages/libraries/router/src/usage.rs +++ b/packages/libraries/router/src/usage.rs @@ -432,11 +432,14 @@ mod hive_usage_tests { plugin::{test::MockSupergraphService, Plugin, PluginInit}, services::supergraph, }; + use http::header::{AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; use httpmock::{Method::POST, Mock, MockServer}; use jsonschema::Validator; use serde_json::json; use tower::ServiceExt; + use crate::consts::PLUGIN_VERSION; + use super::{Config, UsagePlugin}; lazy_static::lazy_static! { @@ -481,17 +484,26 @@ mod hive_usage_tests { fn activate_usage_mock(&'_ self) -> Mock<'_> { self.mocked_upstream.mock(|when, then| { - when.method(POST).path("/usage").matches(|r| { - // This mock also validates that the content of the reported usage is valid - // when it comes to the JSON schema validation. - // if it does not match, the request matching will fail and this will lead - // to a failed assertion - let body = r.body.as_ref().unwrap(); - let body = String::from_utf8(body.to_vec()).unwrap(); - let body = serde_json::from_str(&body).unwrap(); + when.method(POST) + .path("/usage") + .header(CONTENT_TYPE.as_str(), "application/json") + .header( + USER_AGENT.as_str(), + format!("hive-apollo-router/{}", PLUGIN_VERSION), + ) + .header(AUTHORIZATION.as_str(), "Bearer 123") + .header("X-Usage-API-Version", "2") + .matches(|r| { + // This mock also validates that the content of the reported usage is valid + // when it comes to the JSON schema validation. + // if it does not match, the request matching will fail and this will lead + // to a failed assertion + let body = r.body.as_ref().unwrap(); + let body = String::from_utf8(body.to_vec()).unwrap(); + let body = serde_json::from_str(&body).unwrap(); - SCHEMA_VALIDATOR.is_valid(&body) - }); + SCHEMA_VALIDATOR.is_valid(&body) + }); then.status(200); }) } @@ -576,21 +588,4 @@ mod hive_usage_tests { mock.assert(); mock.assert_hits(1); } - - #[tokio::test] - async fn invalid_query_reported() { - let instance = UsageTestHelper::new().await; - let req = supergraph::Request::fake_builder() - .query("query {") - .build() - .unwrap(); - let mock = instance.activate_usage_mock(); - - instance.execute_operation(req).await.next_response().await; - - instance.wait_for_processing().await; - - mock.assert(); - mock.assert_hits(1); - } } diff --git a/packages/libraries/sdk-rs/Cargo.toml b/packages/libraries/sdk-rs/Cargo.toml index 5c63a7ab1..b6a47eaa4 100644 --- a/packages/libraries/sdk-rs/Cargo.toml +++ b/packages/libraries/sdk-rs/Cargo.toml @@ -15,13 +15,12 @@ path = "src/lib.rs" async-trait = "0.1.77" axum-core = "0.5" thiserror = "2.0.11" -reqwest = { version = "0.12.0", default-features = false, features = [ +reqwest = { version = "0.12.24", default-features = false, features = [ "rustls-tls", "blocking", - "json", ] } -reqwest-retry = "0.7.0" -reqwest-middleware = "0.4.0" +reqwest-retry = "0.8.0" +reqwest-middleware = "0.4.2" anyhow = "1" tracing = "0.1" serde = "1" @@ -34,3 +33,5 @@ moka = { version = "0.12.10", features = ["future", "sync"] } sha2 = { version = "0.10.8", features = ["std"] } tokio-util = "0.7.16" +[dev-dependencies] +mockito = "1.7.0" \ No newline at end of file diff --git a/packages/libraries/sdk-rs/src/agent.rs b/packages/libraries/sdk-rs/src/agent.rs index 2bf902817..20c1d013a 100644 --- a/packages/libraries/sdk-rs/src/agent.rs +++ b/packages/libraries/sdk-rs/src/agent.rs @@ -3,7 +3,7 @@ use graphql_parser::schema::Document; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, VecDeque}, sync::{Arc, Mutex}, @@ -12,7 +12,7 @@ use std::{ use thiserror::Error; use tokio_util::sync::CancellationToken; -#[derive(Serialize, Debug)] +#[derive(Serialize, Deserialize, Debug)] pub struct Report { size: usize, map: HashMap, @@ -20,7 +20,7 @@ pub struct Report { } #[allow(non_snake_case)] -#[derive(Serialize, Debug)] +#[derive(Serialize, Deserialize, Debug)] struct OperationMapRecord { operation: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -29,7 +29,7 @@ struct OperationMapRecord { } #[allow(non_snake_case)] -#[derive(Serialize, Debug)] +#[derive(Serialize, Deserialize, Debug)] struct Operation { operationMapKey: String, timestamp: u64, @@ -41,20 +41,20 @@ struct Operation { } #[allow(non_snake_case)] -#[derive(Serialize, Debug)] +#[derive(Serialize, Deserialize, Debug)] struct Execution { ok: bool, duration: u128, errorsTotal: usize, } -#[derive(Serialize, Debug)] +#[derive(Serialize, Deserialize, Debug)] struct Metadata { #[serde(skip_serializing_if = "Option::is_none")] client: Option, } -#[derive(Serialize, Debug)] +#[derive(Serialize, Deserialize, Debug)] struct ClientInfo { #[serde(skip_serializing_if = "Option::is_none")] name: Option, @@ -114,7 +114,7 @@ pub struct UsageAgent { } fn non_empty_string(value: Option) -> Option { - value.filter(|str| str.is_empty()) + value.filter(|str| !str.is_empty()) } #[derive(Error, Debug)] @@ -171,6 +171,7 @@ impl UsageAgent { .connect_timeout(connect_timeout) .timeout(request_timeout) .user_agent(user_agent) + .default_headers(default_headers) .build() .map_err(AgentError::HTTPClientCreationError)?; let client = ClientBuilder::new(reqwest_agent) @@ -269,6 +270,9 @@ impl UsageAgent { } pub async fn send_report(&self, report: Report) -> Result<(), AgentError> { + if report.size == 0 { + return Ok(()); + } let report_body = serde_json::to_vec(&report).map_err(|e| AgentError::Unknown(e.to_string()))?; // Based on https://the-guild.dev/graphql/hive/docs/specs/usage-reports#data-structure @@ -357,3 +361,200 @@ impl UsageAgentExt for Arc { Ok(()) } } + +#[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::{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] + async fn should_send_data_to_hive() { + 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 = format!("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.as_str()) + .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.operationName.as_deref(), Some("deleteProject")); + // fields + let expected_fields = vec![ + "Mutation.deleteProject", + "Mutation.deleteProject.selector", + "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())); + } + assert_eq!(record.fields.len(), expected_fields.len()); + + // 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.operationMapKey, key); + assert_eq!(operation.timestamp, timestamp); + assert_eq!(operation.execution.duration, duration.as_nanos()); + assert_eq!(operation.execution.ok, true); + assert_eq!(operation.execution.errorsTotal, 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 + } + "#, + ) + .expect("Failed to parse schema"); + + 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 + } + "#, + ) + .expect("Failed to parse query"); + + let usage_agent = UsageAgent::try_new( + token, + format!("{}/200", server_url), + None, + 10, + Duration::from_millis(500), + Duration::from_millis(500), + false, + Duration::from_millis(10), + user_agent, + ) + .expect("Failed to create UsageAgent"); + + 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, + }) + .expect("Failed to add report"); + + usage_agent.flush().await; + + mock.assert_async().await; + } +}